requestAnimationFrame Timing
Where rAF Fires in the Event Loop
Let's clear up the biggest misconception right away: requestAnimationFrame is not a timer. It is a rendering lifecycle hook — the browser calls your callback once per frame, at a very specific point in the frame pipeline:
┌─────────── Event Loop Iteration ───────────┐
│ │
│ 1. Macrotask (setTimeout, I/O, events) │
│ 2. Microtask queue drains (Promises, etc.) │
│ 3. Rendering pipeline begins: │
│ a. requestAnimationFrame callbacks ←──── HERE
│ b. Style recalculation │
│ c. Layout │
│ d. Paint │
│ e. Composite │
│ 4. requestIdleCallback (if time remains) │
│ │
└─────────────────────────────────────────────┘
rAF fires after microtasks drain and before the browser calculates styles and layout. This is the ideal time for DOM mutations that should be reflected in the next visual frame — the browser processes your changes immediately after, in the same frame.
rAF is the browser saying: "I am about to paint a new frame. This is your last chance to make changes that will appear in this frame." Any DOM reads/writes in rAF are batched with the browser's own style/layout work. Unlike setTimeout (which fires at an arbitrary time), rAF is synchronized to the display's refresh rate.
The DOMHighResTimeStamp
Every rAF callback receives a gift: a high-resolution timestamp marking when the current batch of rAF callbacks began, measured in milliseconds from the page's time origin:
function animate(timestamp) {
// timestamp: DOMHighResTimeStamp (e.g., 1042.567)
// Same value for ALL rAF callbacks in this frame
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1);
element.style.transform = `translateX(${progress * 300}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
const startTime = performance.now();
requestAnimationFrame(animate);
Date.now() has millisecond resolution and is subject to clock adjustments. The rAF timestamp has microsecond resolution and is monotonically increasing. Using Date.now() in animation loops causes subtle timing jitter. Always use the timestamp provided by rAF or performance.now().
rAF vs setTimeout for Animations
You might be wondering: "Can't I just use setTimeout(fn, 16) and get basically the same thing?" You can try. But here is why it falls apart.
setTimeout Problems
// ❌ setTimeout-based animation
function animate() {
element.style.transform = `translateX(${x++}px)`;
setTimeout(animate, 16); // ~60fps... in theory
}
Issues:
- Not synchronized to display refresh: setTimeout fires relative to when it was set, not the display's vsync. Timing drift causes frames to be skipped or doubled.
- Minimum 4ms clamp: Nested setTimeout has a minimum ~4ms delay (HTML spec), causing drift away from 16.67ms.
- Background tab behavior: setTimeout fires at reduced rate (~1/second) in background tabs but still runs, wasting CPU.
- No frame coordination: Multiple setTimeout-based animations drift out of sync with each other.
rAF Advantages
// ✅ rAF-based animation
function animate(timestamp) {
element.style.transform = `translateX(${x++}px)`;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
- Vsync-aligned: Fires exactly once per display refresh (60Hz, 120Hz, 144Hz — adapts automatically)
- Automatic pausing: Does not fire when the tab is in the background (zero CPU waste)
- Coordinated timing: All rAF callbacks in a frame execute together, before a single style/layout/paint pass
- Precise timestamp: Microsecond-resolution DOMHighResTimeStamp for consistent animation math
The Double-rAF Trick
This one is a classic. Sometimes you need to run code after the browser has painted the current frame — not before paint (where rAF runs), but after. The double-rAF trick is the go-to workaround, scheduling code for the frame after the next paint:
// Runs before paint (normal rAF)
requestAnimationFrame(() => {
// This runs in frame N, before paint
requestAnimationFrame(() => {
// This runs in frame N+1, before paint
// But frame N has already painted — so this is "after frame N's paint"
measurePostPaintState();
});
});
When You Need Double-rAF
Measuring layout after a style change:
// Apply style change
element.classList.add('expanded');
// Need to measure the result AFTER the browser has painted
requestAnimationFrame(() => {
// Frame N: browser will paint the 'expanded' state
requestAnimationFrame(() => {
// Frame N+1: 'expanded' has been painted
const height = element.offsetHeight; // Reflects painted state
triggerAnimation(height);
});
});
Ensuring a transition starts from the correct state:
// Insert element and ensure it starts at opacity 0
container.appendChild(element);
element.style.opacity = '0';
// Wait for the browser to paint opacity: 0
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Now transition from 0 to 1
element.style.transition = 'opacity 0.3s';
element.style.opacity = '1';
});
});
The double-rAF trick is fragile. On some browsers and under heavy load, the two rAF callbacks may execute in the same frame (if the browser is catching up on missed frames). A more reliable approach for post-paint scheduling uses MessageChannel or setTimeout(fn, 0) inside a rAF callback — the macrotask fires after the paint, guaranteed:
requestAnimationFrame(() => {
// Schedule a macrotask — fires after this frame's paint
setTimeout(() => {
// This is guaranteed to run after paint
measureAfterPaint();
}, 0);
});cancelAnimationFrame
const id = requestAnimationFrame(animate);
// Cancel before the callback fires
cancelAnimationFrame(id);
Always cancel pending rAF callbacks when:
- A component unmounts (memory leak prevention)
- An animation is interrupted by a new one
- The user navigates away from the animated view
// React cleanup pattern
useEffect(() => {
let rafId: number;
function animate(timestamp: number) {
// ... animation logic
rafId = requestAnimationFrame(animate);
}
rafId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafId);
}, []);
Frame Timing Variability
Here is something that catches people off guard: rAF does not guarantee a consistent 16.67ms interval. Frame timing varies due to:
- Main thread work: A 30ms JavaScript task pushes the rAF callback later
- GC pauses: Garbage collection can delay frame rendering
- GPU back-pressure: If the GPU cannot composite fast enough, frames are delayed
- Display refresh rate: 120Hz gives 8.33ms intervals; 30Hz throttled tabs give 33.33ms
Always use the timestamp for animation calculations, never assume a fixed interval:
// ❌ Assumes fixed frame rate
function animate() {
x += 5; // 5px per frame — speed depends on frame rate
element.style.transform = `translateX(${x}px)`;
requestAnimationFrame(animate);
}
// ✅ Time-based — consistent speed regardless of frame rate
function animate(timestamp) {
const delta = timestamp - lastTimestamp;
lastTimestamp = timestamp;
x += speed * delta; // px per millisecond — frame rate independent
element.style.transform = `translateX(${x}px)`;
requestAnimationFrame(animate);
}
rAF in React
React uses rAF internally for scheduling, but you rarely interact with it directly:
- React's scheduler uses MessageChannel (not rAF) for work scheduling, but hooks into rAF for synchronizing state updates with the browser's render cycle
- Concurrent features (startTransition, useDeferredValue) use scheduler priorities that yield to rAF-timed rendering
- useEffect runs after paint (like double-rAF timing), making it the correct place for post-render measurements
// Direct rAF usage in React — for custom animations
function useAnimationFrame(callback: (deltaTime: number) => void) {
const rafRef = useRef<number>(0);
const previousTimeRef = useRef<number>(0);
useEffect(() => {
function animate(time: number) {
if (previousTimeRef.current) {
const delta = time - previousTimeRef.current;
callback(delta);
}
previousTimeRef.current = time;
rafRef.current = requestAnimationFrame(animate);
}
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current);
}, [callback]);
}
requestIdleCallback: the other frame hook
requestIdleCallback fires after the browser has completed all frame work (rAF, style, layout, paint, composite) and there is idle time remaining before the next frame deadline. It receives an IdleDeadline object with timeRemaining() — the milliseconds until the next frame. Use it for non-urgent work: analytics, prefetching, background computation. Unlike rAF, idle callbacks are NOT guaranteed to fire every frame — they only fire when the browser has spare time. They may be delayed indefinitely during heavy animation or interaction.
- 1rAF fires once per display frame, after microtasks and before style/layout/paint. It is the correct time for DOM mutations.
- 2All rAF callbacks in a frame receive the same DOMHighResTimeStamp. Use it for animation math, not Date.now().
- 3rAF pauses in background tabs (saving CPU). setTimeout does not — it fires at ~1/second.
- 4Use time-based animation (speed × deltaTime) not per-frame increments (x += 5). Per-frame speed varies with refresh rate.
- 5The double-rAF trick schedules code after paint. More reliably: rAF + setTimeout(0) guarantees post-paint execution.
- 6Always cancelAnimationFrame on cleanup (component unmount, animation replacement, navigation).
- 7React uses MessageChannel, not rAF, for its scheduler — but useEffect runs after paint, similar to double-rAF timing.
Q: Explain the difference between requestAnimationFrame and setTimeout for animations. When would you use each?
A strong answer covers: rAF is vsync-aligned (fires once per display refresh), runs before style/layout/paint, pauses in background tabs, provides high-resolution timestamps, and coordinates multiple animation callbacks in a single frame. setTimeout is not aligned to display refresh, has a minimum ~4ms clamp for nested calls, continues in background tabs (wasting resources), and causes timing drift. Use rAF for any visual animation. Use setTimeout for delayed non-visual logic. Mention time-based animation vs per-frame increments for frame-rate-independent speed. Bonus: explain the double-rAF trick and rAF's position in the event loop rendering pipeline.