requestAnimationFrame and Scheduling
Date: 2026-08-17
Running work in step with the frame rather than against it.
requestAnimationFrameis the callback the browser fires immediately before it paints — which makes it the only correct place to write visual changes, and the wrong place for anything that isn’t one.
requestAnimationFrame schedules a callback to run before the browser’s next repaint; scheduling more broadly is choosing which queue — task, microtask, frame, idle — a piece of work goes in.
Where it sits in the frame
one frame at 60fps ≈ 16.7ms
┌─────────────────────────────────────────────────────────┐
│ tasks (events, timers, network callbacks) │
│ ↓ microtasks drain after each task │
│ requestAnimationFrame callbacks ← YOUR VISUAL WORK │
│ ↓ │
│ style → layout → paint → composite ← the browser's │
│ ↓ │
│ requestIdleCallback, if there's time left over │
└─────────────────────────────────────────────────────────┘
rAF runs after all pending work and immediately before style and layout. That position is the whole point: a change written there is guaranteed to be applied in this frame, exactly once, without the browser having already laid out based on an earlier value — Tasks and Microtasks, The Rendering Pipeline.
Why not setTimeout
// ✗ drifts, fires at the wrong moment, runs in background tabs
setTimeout(step, 16);
// ✓ aligned with the browser's paint, throttled when hidden
requestAnimationFrame(step);setTimeout(fn, 16) requestAnimationFrame(fn)
fires ~16ms later, ± drift fires exactly before the next paint
no relationship to the frame synchronised with it
can fire twice between paints exactly once per frame
→ wasted work, or a skipped
visual state
runs in a hidden tab paused in a hidden tab
← saves battery, and avoids a
backlog on return
assumes 60fps adapts to 120Hz displays and to
a struggling device
The timestamp argument is the one people ignore, and using it is what makes animation frame-rate independent:
let start;
function step(timestamp) { // high-resolution, ms
start ??= timestamp;
const elapsed = timestamp - start;
// position derived from TIME, not from frame count
el.style.transform = `translateX(${Math.min(elapsed / 4, 300)}px)`;
if (elapsed < 1200) requestAnimationFrame(step);
}
requestAnimationFrame(step);Incrementing by a fixed amount per frame runs at double speed on a 120Hz display and in slow motion on a struggling one. Deriving from elapsed time is correct everywhere.
The read/write batching pattern
The highest-value use, and the fix for layout thrashing:
// ✗ forced synchronous layout, once per iteration
els.forEach(el => {
el.style.height = el.offsetHeight * 2 + 'px'; // read → write → read…
});
// ✓ read now, write in the frame
const heights = els.map(el => el.offsetHeight); // all reads together
requestAnimationFrame(() => {
els.forEach((el, i) => el.style.height = heights[i] * 2 + 'px');
});Reading a geometric property after a write forces the browser to recompute layout immediately, before it wanted to. Batching reads, then writing inside rAF, means one layout pass instead of N — Reflow and Repaint, The CSSOM.
What doesn’t belong in it
rAF callbacks run on the main thread and block the frame. Anything slow inside one causes exactly the jank it was meant to prevent.
✓ in rAF: style and transform writes, canvas drawing,
scroll-linked positioning
✗ in rAF: data processing, fetch handling, parsing,
anything over ~4ms
→ use a worker, or yield — Web Workers
Anything computational belongs on another thread — Web Workers.
And prefer CSS for animation where it’s expressible. A CSS transition on transform or opacity runs on the compositor thread and continues smoothly even when the main thread is busy; a rAF loop writing the same property does not — Compositing and Layers, Animation Performance.
CSS transition/animation compositor thread. survives main-thread jank.
Web Animations API same engine, controllable from JS
requestAnimationFrame main thread. use when the value depends on
something JS must compute each frame
The other schedulers
requestIdleCallback(fn) runs when the browser has spare time.
for genuinely deferrable work — prefetching,
analytics flushing, cache cleanup.
ALWAYS pass a timeout, or on a busy page
it may never run
scheduler.postTask(fn, explicit priority: 'user-blocking',
{ priority }) 'user-visible', 'background'.
the modern general-purpose option
[CHECK: availability varies by browser —
verify support before relying on it]
scheduler.yield() yield to the main thread mid-task, then
continue. cleaner than the setTimeout(0)
trick for breaking up long work
Yielding is the technique worth internalising for long synchronous work you can’t move to a worker: process a chunk, hand control back so the browser can respond to input and paint, then continue — Long Tasks and Blocking, Interaction to Next Paint.
Practical
- Cancel with
cancelAnimationFramewhen a component unmounts, or the loop runs forever holding its closure — Memory and Long Sessions - One loop, not one per element. A single rAF updating fifty things beats fifty callbacks
- Never
setTimeout(fn, 0)to “wait for the DOM”. It’s a race. Use rAF, or two nested rAFs where you need the browser to have painted first - Passive listeners for scroll and touch, so the compositor doesn’t wait to find out whether you’ll call
preventDefault— Event Handling - Respect
prefers-reduced-motionbefore starting any animation loop — Reduced Motion
Where it interacts
- The Event Loop — the model this sits inside, and where rAF falls relative to tasks
- Tasks and Microtasks — the ordering rules that put rAF where it is
- Reflow and Repaint — the layout cost batching exists to avoid
- Compositing and Layers — why CSS animation beats a JavaScript loop when it’s an option