Web Platform · JavaScript Runtime

Rendering Opportunities and When Animation Frame Callbacks Run

How the browser decides to paint a frame, what requestAnimationFrame is synchronized to, and why callbacks registered during a frame land in the next one.

Between two displayed frames the browser may run any number of tasks. It may run none. Painting is not a consequence of changing the DOM, and it is not something a script can request directly; it is a step the event loop performs when the host decides there is a rendering opportunity. Everything about requestAnimationFrame follows from that: the callback is not a timer, it is a hook into the frame that is about to be produced.

The distinction matters because the frame’s rendering stages are where style, layout, paint, and compositing happen, and those stages are expensive enough that running them once per task instead of once per display refresh would be wasteful. A page that mutates the DOM a hundred times within one task gets one layout, not a hundred. The loop’s job is to collect mutations until the frame boundary, then reconcile them in a single pass.

What makes a frame happen

The standard’s processing model includes an optional step: if the event loop’s rendering opportunity condition holds for a document — the document is visible, its browsing context is not throttled, and the host has decided it is time — the browser runs the update-the-rendering steps for every such document. There is no fixed interval in the specification. In practice the host aligns the step with the display’s refresh, commonly 60 Hz, which gives a nominal 16.7 ms per frame, and on displays running at 120 Hz, 8.3 ms. A page in a background tab has no rendering opportunity at all, because nothing is visible; this is why animation loops appear to pause when a tab is hidden and why requestAnimationFrame callbacks stop arriving there while timers continue at a throttled rate.

The steps are ordered and include, in rough sequence: running the window resize steps and scroll steps, evaluating media queries, updating animations and dispatching their events, running fullscreen steps, invoking requestAnimationFrame callbacks, then style recalculation, layout, paint, and finally handing the result to the compositor. Observer callbacks fit into this sequence at defined points — resize observers after layout has computed new sizes, intersection observers once geometry is known — which is why they are suited to reacting to layout rather than to driving it.

Layout and paint are normally deferred to the frame boundary, and a task that reads layout and then writes style forces the browser to run layout synchronously inside the task instead, after which the next rendering opportunity may run it again. The cost is not the individual property access; it is the extra layout pass per read-write alternation, each proportional to the number of nodes affected.

The callback contract

requestAnimationFrame(callback) appends the callback to the document’s list of animation frame callbacks and returns a handle. When the rendering step reaches that list, it records the outstanding handles, then removes each entry and invokes it with a timestamp. Three properties follow from that wording:

  • The timestamp is the frame’s time. All callbacks in one frame receive the same value, and it is the time associated with the beginning of the frame’s rendering, not the moment the callback ran. It is a DOMHighResTimeStamp in milliseconds, on the same time origin as performance.now(), though browsers coarsen its resolution to limit timing side channels.
  • Callbacks registered during the frame wait for the next one. The handles were recorded before any callback ran, so a callback that calls requestAnimationFrame again schedules work for the following frame. A loop built this way advances exactly one step per frame.
  • Order is registration order within a document. Multiple callbacks on the same document run in the order requested; callbacks on different documents are independent.

Cancelling with cancelAnimationFrame(handle) before the frame starts removes the entry. During the frame, each callback is removed from the map just before it is invoked, so cancelling a later callback from an earlier one does take effect, while cancelling the callback that is already running does not. The handle is scoped to the window that produced it; cancelling through a different window consults that window’s own callback list, so it cannot cancel the callback the handle came from.

let previous;
function step(timestamp) {
  if (previous === undefined) previous = timestamp;
  const delta = timestamp - previous;   // milliseconds since the last frame
  previous = timestamp;
  // Advance animation state by delta, not by a fixed step.
  requestAnimationFrame(step);
}
requestAnimationFrame(step);

Why the delta, not the frame count

A loop that assumes each callback advances the animation by one sixtieth of a second drifts on any display whose refresh differs from 60 Hz, and jumps after a stall. The timestamp exists so that motion can be a function of elapsed time. The frame count is not a clock: frames are dropped when a frame’s work exceeds the budget, and on a 120 Hz display the loop runs twice as often for the same wall-clock second.

Dropped frames are the normal failure mode. If the combined cost of the callbacks plus style, layout, and paint exceeds the frame interval, the compositor misses the deadline and the frame is presented late or skipped. The animation still advances correctly if it is time-based, but it appears to stutter. This is also why a single long task in the middle of an animation causes a visible hitch: no rendering opportunity can complete until the task yields, so several display refreshes pass with the same pixels.

Compositor-driven properties change the picture. Transforms and opacity can be animated by the compositor on its own thread, without waiting for a rendering opportunity in the document’s event loop, so a CSS transition on transform can continue smoothly while the main thread is busy. Animating top or width cannot: those require layout, which requires the main thread.

Idle time is not frame time

requestIdleCallback addresses a different question — what to do when there is nothing to do. Its callbacks run when the event loop is idle, which on a page with continuous animation may be never, and it passes a deadline object whose timeRemaining() reports the estimated budget left in the current idle period. The standard sets a maximum idle period, and browsers commonly cap it at 50 ms. A callback that overruns the deadline still blocks; the API offers no preemption, only an estimate.

The distinction from requestAnimationFrame is architectural: frame callbacks are part of the frame and run before layout, so their writes are included in that frame. Idle callbacks run outside the frame, so their writes trigger another rendering pass later. Using an idle callback to change visual state therefore delays the change by at least one frame, sometimes more.

Interaction with the other queues

requestAnimationFrame callbacks are neither tasks nor microtasks. Microtasks queued before the frame drain first. Each animation frame callback is invoked as a separate entry into script, so a microtask checkpoint follows it: a promise reaction scheduled inside one callback runs before the next callback of the same frame, and well before style recalculation. If a callback throws, the error is reported to the global error handler and the remaining callbacks in that frame still run, because each invocation is separate.

For work that must happen as soon as possible after the current task but before the frame, a microtask is the right tool; for work that must be included in the next frame’s layout, an animation frame callback is. Choosing the wrong one produces either an extra frame of latency or an extra layout pass. The scheduler’s internal notion of frame deadlines is exposed to page code only through these two hooks, and neither is a hard real-time guarantee — a busy main thread delays both.

References

  1. HTML Living Standard — Update the rendering — WHATWG
  2. Window.requestAnimationFrame() — MDN Web Docs
  3. HTML Living Standard — Event loops: rendering opportunities — WHATWG