DEV Community

Gabriele B.
Gabriele B.

Posted on

Playback System - Complete Rewrite

Summary of Changes

The playback system has been completely rewritten to fix critical issues causing:

  • ❌ Playhead moving backward during playback
  • ❌ Severe lag and stuttering
  • ❌ High CPU usage
  • ❌ Unpredictable animation behavior

What Was Wrong

The Core Problem: Dependency Cycle

The original usePlayback.ts had a fatal design flaw:

// OLD CODE - BROKEN
useEffect(() => {
  const animate = (timestamp: number) => {
    const newTime = currentTime + deltaSeconds; // ⚠️ Uses state variable
    setCurrentTime(newTime); // ⚠️ Triggers re-render
    requestAnimationFrame(animate);
  };
  requestAnimationFrame(animate);
}, [isPlaying, currentTime, duration, loop, setCurrentTime, setPlaying]);
//              ^^^^^^^^^^^ THIS CAUSED THE BUG
Enter fullscreen mode Exit fullscreen mode

The Cascade of Failures:

  1. Animation frame runs → setCurrentTime(0.016) → State updates
  2. React re-renders → useEffect sees currentTime changed
  3. Effect UNMOUNTS old animation loop
  4. Effect REMOUNTS new animation loop
  5. lastTimeRef.current = performance.now() resets
  6. Next frame calculates delta from wrong baseline
  7. Result: Time jumps backward, severe lag

This happened 60 times per second, causing:

  • Constant effect remounting
  • Garbage collection pressure
  • Timing drift and errors
  • Backward playhead movement

Why It Couldn't Be Fixed With Simple Changes

The problem wasn't just the dependency array. The fundamental architecture was wrong:

  • Animation loop tied to React's render cycle
  • State updates triggering effect re-runs
  • No separation between internal timing and UI updates

The New Architecture

Core Principle: Decouple Animation from Rendering

The new system uses refs for internal state and state for UI updates:

// Refs - Track internal state without triggering re-renders
const internalTimeRef = useRef<number>(currentTime);
const isPlayingRef = useRef<boolean>(isPlaying);
const durationRef = useRef<number>(duration);
const loopRef = useRef<boolean>(loop);
const fpsRef = useRef<number>(fps);

// Animation loop uses ONLY refs
const animate = (timestamp: number) => {
  const newTime = internalTimeRef.current + deltaSeconds; // ✅ Uses ref
  internalTimeRef.current = newTime; // ✅ Updates ref
  setCurrentTime(newTime); // ✅ Updates UI (doesn't restart loop)
};
Enter fullscreen mode Exit fullscreen mode

Key Improvements

1. Refs Prevent Stale Closures

Sync effects keep refs updated:

useEffect(() => {
  internalTimeRef.current = currentTime;
}, [currentTime]);

useEffect(() => {
  durationRef.current = duration;
}, [duration]);
Enter fullscreen mode Exit fullscreen mode

This ensures:

  • External seeking updates internal time
  • Sequence changes update duration/fps
  • No stale values in animation loop

2. Animation Loop Runs Once

useEffect(() => {
  if (!isPlaying) return;

  // Start animation loop ONCE
  const animate = (timestamp: number) => {
    // Uses refs - no stale closures
    // Updates state - doesn't restart loop
    requestAnimationFrame(animate);
  };

  requestAnimationFrame(animate);
}, [isPlaying, setCurrentTime, setPlaying]);
// ✅ currentTime, duration, loop, fps NOT in dependencies
Enter fullscreen mode Exit fullscreen mode

The loop:

  • Starts when isPlaying becomes true
  • Runs continuously until stopped
  • Never restarts mid-playback
  • Uses refs for all internal state

3. Proper State Management

const togglePlay = useCallback(() => {
  if (isPlayingRef.current) {
    pause();
  } else {
    // Restart from beginning if at end
    if (internalTimeRef.current >= durationRef.current) {
      internalTimeRef.current = 0;
      setCurrentTime(0);
    }
    play();
  }
}, [play, pause, setCurrentTime]);
Enter fullscreen mode Exit fullscreen mode

All callbacks use refs to check current values, preventing stale closures.

How It Works Now

Playback Flow

  1. User presses playsetPlaying(true)
  2. useEffect triggers (only for isPlaying change)
  3. Animation loop starts:
   Frame 0: timestamp = 1000, delta = 0, time = 0.000
   Frame 1: timestamp = 1016, delta = 16ms, time = 0.016
   Frame 2: timestamp = 1033, delta = 17ms, time = 0.033
   Frame 3: timestamp = 1049, delta = 16ms, time = 0.049
   ... smooth forward progression
Enter fullscreen mode Exit fullscreen mode
  1. Each frame:
    • Calculates delta from last frame
    • Updates internalTimeRef.current
    • Calls setCurrentTime() for UI
    • Schedules next frame
  2. Effect does NOT re-run because currentTime not in deps
  3. Result: Smooth, accurate, forward-only playback

Seeking Flow

  1. User drags playheadseekTo(2.5) called
  2. seekTo updates both:
   internalTimeRef.current = 2.5; // Internal state
   setCurrentTime(2.5);           // UI state
Enter fullscreen mode Exit fullscreen mode
  1. Sync effect runs: internalTimeRef.current = currentTime
  2. Next animation frame uses correct time
  3. No discontinuity or time jump

Sequence Changes

When a sequence changes (fps, duration):

  1. AnimationContext updates timeline state
  2. Sync effects run:
   durationRef.current = newDuration;
   fpsRef.current = newFps;
Enter fullscreen mode Exit fullscreen mode
  1. Animation loop uses new values immediately
  2. No restart needed

Performance Improvements

Before (Broken System)

  • ⚠️ Effect remounts: 60 times/second
  • ⚠️ State updates: 60 times/second
  • ⚠️ Cleanup functions: 60 times/second
  • ⚠️ Setup functions: 60 times/second
  • ⚠️ CPU usage: Very high
  • ⚠️ Frame rate: Unstable, dropping
  • ⚠️ Timing: Inaccurate, backward movement

After (New System)

  • ✅ Effect remounts: 1 time on play/pause
  • ✅ State updates: 60 times/second (normal)
  • ✅ Cleanup functions: 1 time on stop
  • ✅ Setup functions: 1 time on start
  • ✅ CPU usage: Normal
  • ✅ Frame rate: Stable 60 FPS
  • ✅ Timing: Accurate, smooth forward

Edge Cases Handled

1. Seeking While Playing

seekTo(5.0)  internalTimeRef updates  sync effect runs  continues smoothly
Enter fullscreen mode Exit fullscreen mode

✅ No time jump or discontinuity

2. Changing Duration Mid-Playback

setDuration(20)  durationRef updates  animation continues with new limit
Enter fullscreen mode Exit fullscreen mode

✅ No restart, smooth transition

3. Reaching End

if (newTime >= durationRef.current) {
  if (loopRef.current) {
    // Loop back
  } else {
    // Stop cleanly
  }
}
Enter fullscreen mode Exit fullscreen mode

✅ Proper loop or stop behavior

4. Pause/Resume

pause()  cancels RAF  isPlayingRef = false
play()  starts new RAF loop  lastTimestampRef resets
Enter fullscreen mode Exit fullscreen mode

✅ No time jump from pause duration

5. Stop/Restart

stop()  time = 0, cancels RAF
play()  starts from 0
Enter fullscreen mode Exit fullscreen mode

✅ Clean restart behavior

Testing Checklist

Test these scenarios to verify the fix:

Basic Playback

  • [x] Press play → playhead moves forward smoothly
  • [x] No backward movement at any point
  • [x] Consistent speed (30 FPS = 0.033s per frame)
  • [x] Time display matches actual progression

Seeking

  • [x] Seek while paused → updates immediately
  • [x] Seek while playing → continues from new position
  • [x] Seek to start → resets to 0
  • [x] Seek to end → stops at duration

Controls

  • [x] Play/pause → toggles correctly
  • [x] Stop → returns to start
  • [x] Step forward → advances one frame
  • [x] Step backward → goes back one frame

Looping

  • [x] Enable loop → reaches end → restarts at 0
  • [x] Disable loop → reaches end → stops

Performance

  • [x] Open DevTools Performance tab
  • [x] Record during playback
  • [x] Verify smooth 60 FPS
  • [x] No excessive re-renders
  • [x] Normal CPU usage

Sequences

  • [x] Create sequence with 24 FPS → playback respects frame rate
  • [x] Change duration → playback adapts
  • [x] Switch sequences → timeline updates correctly

Technical Details

Refs vs State

When to use Refs:

  • Internal timing values
  • Values that change every frame
  • Values needed in animation loop
  • Values that shouldn't trigger re-renders

When to use State:

  • UI display values
  • User-controllable values
  • Values that need to trigger re-renders
  • Values that affect component output

Why This Works

  1. Refs are mutable without triggering re-renders
  2. Sync effects keep refs updated from external changes
  3. Animation loop runs independently of React's render cycle
  4. State updates only affect UI, not loop timing
  5. No circular dependencies between state and effects

Performance Characteristics

  • Memory: Minimal (few extra refs)
  • CPU: Optimal (one RAF loop, no restart overhead)
  • Timing accuracy: High (no drift from restarts)
  • Frame rate: Stable (60 FPS on most displays)

Migration Notes

No Breaking Changes

The external API remains identical:

const {
  play, pause, stop, togglePlay,
  seekTo, seekToFrame,
  isPlaying, currentTime, currentFrame,
  // ... all the same
} = usePlayback();
Enter fullscreen mode Exit fullscreen mode

Internal Changes Only

All changes are internal to usePlayback.ts. No other files need updates.

Backward Compatible

Existing code using usePlayback() works without modification.

Conclusion

The playback system has been fundamentally redesigned to:

  1. Separate concerns: Animation timing vs UI rendering
  2. Use refs for internal state: Avoid re-render loops
  3. Use state for UI updates: Keep display in sync
  4. Run animation loop once: No restarts mid-playback
  5. Handle all edge cases: Seeking, looping, sequences

Result: Smooth, accurate, performant playback that respects sequences and provides a professional animation experience.


Code Comparison

Before (Broken)

useEffect(() => {
  lastTimeRef.current = performance.now();

  const animate = (timestamp: number) => {
    const deltaMs = timestamp - lastTimeRef.current;
    const newTime = currentTime + deltaSeconds; // ⚠️ Stale closure
    setCurrentTime(newTime); // ⚠️ Triggers remount
    requestAnimationFrame(animate);
  };

  requestAnimationFrame(animate);
}, [isPlaying, currentTime, ...]);
// ⚠️ Remounts 60 times/second
Enter fullscreen mode Exit fullscreen mode

After (Fixed)

const internalTimeRef = useRef<number>(currentTime);

useEffect(() => {
  internalTimeRef.current = currentTime;
}, [currentTime]); // ✅ Sync only

useEffect(() => {
  const animate = (timestamp: number) => {
    const newTime = internalTimeRef.current + deltaSeconds; // ✅ Always current
    internalTimeRef.current = newTime; // ✅ Update ref
    setCurrentTime(newTime); // ✅ Update UI (doesn't remount)
    requestAnimationFrame(animate);
  };

  requestAnimationFrame(animate);
}, [isPlaying]); // ✅ Runs once per play/pause
Enter fullscreen mode Exit fullscreen mode

The difference: Refs break the dependency cycle and allow the animation loop to run independently of React's render cycle.

Top comments (0)