cswork / libraryOpen practice workspace

Articles

requestAnimationFrame Interview Questions: Frame Timing, Delta Time, and Cleanup

Practice requestAnimationFrame interview answers with 60/120 Hz timestamp math, delta time, hidden-tab behavior, cancellation, and lifecycle cleanup.

“Move this element at 180 pixels per second. It must behave the same on a 60 Hz and a 120 Hz display, pause cleanly in a hidden tab, and leave no callback behind when the component is removed.” That is a better requestAnimationFrame interview exercise than reciting that the browser calls a function before repaint. The answer hinges on elapsed time, lifecycle ownership, and work done inside each frame.

This article gives an original timestamp trace and a browser-ready controller you can explain aloud. Evidence boundary: MDN supports browser API behavior and Page Visibility events; web.dev supports the layout-performance discussion. The trace, controller, test cases, and interview rubric are editorial examples. No candidate process claim is made.

What does requestAnimationFrame schedule?

window.requestAnimationFrame(callback) asks the browser to call callback before the next repaint. It returns a request ID that can be passed to cancelAnimationFrame. The callback receives a timestamp, a DOMHighResTimeStamp representing the frame's timing. MDN notes that the callback is one-shot: if the animation should continue, request the next frame from the callback. It also notes that calls are generally paused in background tabs or hidden iframes. These are API properties; the exact frame cadence is not a promise.

The first answer should separate scheduling from speed. A frame callback is an opportunity to update visual state, not a timer that reliably fires every 16.67 milliseconds. Displays can run at 60, 75, 120, or 144 Hz, and a busy main thread can miss frames. Writing x += 3 on every callback makes velocity depend on callback count. At 60 callbacks per second it travels about 180 pixels per second; at 120 it travels about 360. If the question gives a speed in pixels per second, elapsed seconds must enter the calculation.

Use requestAnimationFrame for updates tied to repaint. A frame-by-frame media player with an independent target FPS may need a separate playback clock and can still render on animation frames; requestAnimationFrame alone does not promise an exact video sampling rate. Work that must complete in a hidden tab should not depend on frame callbacks, because browsers may pause them. For a CSS-only transition, CSS animation may express the intent more simply; the interview should explain why script control is necessary.

Worked trace: 60 Hz, 120 Hz, and a delayed frame

For a constant 180 px/s velocity, the update is distance = 180 × deltaMilliseconds / 1000. Assume the first timestamp only initializes the clock. The following trace uses idealized timestamps; actual callbacks need not land on these exact values.

Callback timestampDelta from prior frameAdded distancePosition
0.00 msinitialize0.00 px0.00 px
16.67 ms16.67 ms3.00 px3.00 px
33.33 ms16.66 ms3.00 px6.00 px
50.00 ms16.67 ms3.00 px9.00 px

On a 120 Hz display, timestamps might be 0, 8.33, 16.67, and 25.00 ms. Each interval moves roughly 1.5 px, so after 50 ms the object still moves about 9 px. The frame-count implementation doubles its velocity; the delta-time implementation does not. This is the original numerical asset to rehearse, not a browser benchmark.

Now assume a callback is delayed from 50 ms to 100 ms. A literal elapsed-time update adds 9 px. That preserves average time-based motion but may visually jump. A game simulation may cap a very large delta or use fixed-step integration for physics; a timeline animation may instead derive position directly from timestamp - startTime. Neither choice is universally correct. State whether the product requires wall-clock completion, smooth apparent movement, or deterministic simulation. A cap silently slows elapsed progress; that can be desirable after tab suspension but should be deliberate.

The first callback's timestamp is not guaranteed to be zero, and MDN warns that it can take multiple frames for the first callback to run. Initialize lastTime from the first callback rather than subtracting an uninitialized or unrelated clock. In the same callback chain, use the supplied timestamp consistently. If you seed a start time elsewhere, document.timeline.currentTime is aligned with the animation-frame timestamp according to MDN; mixing arbitrary Date.now() with a high-resolution timestamp creates nonsense deltas.

Implement a controller with explicit ownership

The following small controller moves an element horizontally. It shows the key invariants: at most one pending frame request, first-frame initialization, a hidden-tab pause, and a cleanup function. The translateX write avoids repeatedly reading layout during the loop. It is a browser example, not a React hook; adapt the same ownership rules to your framework's effect lifecycle.

function animateX(element, pixelsPerSecond = 180) {
  let x = 0;
  let lastTime = null;
  let requestId = null;
  let disposed = false;

  function schedule() {
    if (!disposed && !document.hidden && requestId === null) {
      requestId = requestAnimationFrame(frame);
    }
  }

  function frame(timestamp) {
    requestId = null; // This one-shot request has fired.
    if (disposed || document.hidden) return;
    if (lastTime !== null) {
      const deltaSeconds = Math.max(0, timestamp - lastTime) / 1000;
      x += pixelsPerSecond * deltaSeconds;
      element.style.transform = `translateX(${x}px)`;
    }
    lastTime = timestamp;
    schedule();
  }

  function onVisibilityChange() {
    if (document.hidden) {
      if (requestId !== null) cancelAnimationFrame(requestId);
      requestId = null;
      lastTime = null; // No catch-up jump on resume.
    } else {
      schedule();
    }
  }

  document.addEventListener("visibilitychange", onVisibilityChange);
  schedule();

  return function stop() {
    disposed = true;
    if (requestId !== null) cancelAnimationFrame(requestId);
    requestId = null;
    document.removeEventListener("visibilitychange", onVisibilityChange);
  };
}

The code chooses to freeze animation time while hidden. It cancels a pending callback and clears lastTime, so the first visible frame seeds a new clock rather than paying the entire hidden duration as one delta. This is a product policy, not an MDN guarantee that every animation should pause. If a countdown must reflect elapsed wall time, derive its display from a deadline and render again on visibility; do not merely accumulate visible frame deltas.

The requestId = null line at the start of frame matters. The ID represents the one-shot request that just fired. If you leave it non-null, schedule() thinks a request is still pending and the animation stops after one callback. Conversely, calling requestAnimationFrame(frame) twice in different branches can create two live loops, doubling work and movement. A strong answer can identify both bugs from code review.

Cleanup has two parts. cancelAnimationFrame prevents an outstanding scheduled callback from running, and removing the visibilitychange listener prevents the old controller from responding to future tab changes. The disposed guard also protects against a callback already entering execution; cancellation is not a time machine. In React, return this cleanup from the effect that created the controller, and ensure dependencies do not recreate it unnecessarily. If the element is removed while the loop survives, writes to a detached node waste work and can retain references.

Test the clock rather than hoping your monitor is 120 Hz

For an interview, describe a fake scheduler that stores callbacks and returns IDs. Invoke callbacks with controlled timestamps: 1000, 1016.67, 1033.33, then 1041.66. The expected position after the first three intervals at 180 px/s is approximately 7.5 px. Assert on a tolerance rather than exact floating-point strings. Then simulate a 500 ms pause, dispatch visibilitychange, and verify that the next callback initializes without adding 90 px. The specific trace depends on how your fake sets document.hidden; test the policy, not just the arithmetic.

Test stop() after scheduling: the fake scheduler should record cancellation, the listener should be removed, and no later callback should schedule another. Test repeated stop() calls if the component lifecycle may invoke cleanup defensively. Test the at-most-one-pending invariant after visibility toggles. These checks find leaks and duplicate loops more reliably than looking at an animation for ten seconds.

For a motion that must arrive at a target position in a fixed duration, compute a normalized progress value from a start timestamp and clamp it to [0,1], then apply easing and multiply by the distance. That solves a different specification from constant speed. If the interviewer changes the requirement halfway through, say how your state model changes: the velocity controller accumulates deltas, whereas a duration-based transition derives position from progress. Both use frame callbacks for drawing, but their clocks mean different things.

Does requestAnimationFrame prevent layout thrashing?

No. It schedules callback work near rendering; it does not make expensive DOM reads or writes free. A callback that writes a style and then reads offsetWidth can force synchronous layout, especially when repeated across many nodes. Google's web.dev layout guidance explains how forced layout and thrashing can consume frame time. Batch necessary reads before writes, cache stable measurements, and prefer transform/opacity changes when the design allows. Profile the actual page rather than assuming requestAnimationFrame confers performance by itself.

A 60 Hz frame budget is roughly 16.7 ms, but the application does not own all of that time; the browser also needs style, layout, paint, and compositing work. At 120 Hz the interval is about 8.3 ms. Heavy JavaScript inside every callback can cause missed frames even when delta math is correct. Correct speed and smooth rendering are separate properties. Use browser performance traces to identify long tasks, forced layout, or paint costs before optimizing the arithmetic of one multiply.

Accessibility also changes the product decision. If motion is decorative, respect the user's reduced-motion preference and choose a static state or simpler transition. If the animation communicates essential state, provide another way to understand it. An interviewer may not ask about accessibility, but explaining it shows that “correct loop” is not only numerical. Keep this as a concise requirement decision, not a claim that the API itself enforces reduced motion.

Fast interview answers to common follow-ups

Why use the callback timestamp instead of adding a fixed amount? It makes motion depend on elapsed time, not monitor refresh rate or skipped callbacks. Initialize it on the first callback and keep the same clock source.

Why not always cap delta? A cap prevents a large jump after a stall, but it changes elapsed-time semantics. For a decorative loop that pauses when hidden, reset the clock. For a deadline countdown, calculate remaining time from the deadline. For physics, use an explicit simulation step policy.

Is a callback queued forever if I omit cancellation? A pending request is one-shot, but a callback that schedules another creates a continuing loop. Stop the chain and remove related listeners when the owner unmounts or the animation ends.

Will it run exactly at the display refresh rate? No. MDN describes typical alignment with repaint and background pausing; main-thread load and browser scheduling can skip or delay frames. Treat the callback as a rendering opportunity, not a precise metronome.

How do you answer a janky animation report? Reproduce it, record a performance trace, separate incorrect time math from expensive per-frame work, inspect layout reads after writes, then test on a high-refresh device and after a hidden-tab resume.

Sources and further reading