requestAnimationFrame and Idle Callback
The Animation That Jitters
A developer builds a progress bar animation using setInterval:
let width = 0;
setInterval(() => {
width += 1;
bar.style.width = width + '%';
}, 16); // 16ms ≈ 60fps, right?
It looks fine on their fast MacBook. On a mid-range Android phone, the animation jitters. Sometimes it skips frames. Sometimes it runs too fast when the tab regains focus. The problem: setInterval has nothing to do with the browser's rendering pipeline.
requestAnimationFrame exists specifically to solve this. But most developers use it without understanding where it sits in the event loop — and that misunderstanding leads to subtle, hard-to-debug rendering bugs.
The Mental Model
Think of the browser's render cycle as a movie projector. The projector advances one frame every 16.6ms (at 60fps). requestAnimationFrame is like slipping a note to the projectionist: "Before you show the next frame, make these changes to the slide." The projectionist processes ALL such notes, then advances the frame. setTimeout is more like a general mailbox — the mail arrives when it arrives, with no coordination with the projector's schedule.
Where rAF Lives in the Event Loop
Recall the event loop algorithm:
while (true) {
task = pickNextTask(); // 1. Macrotask
execute(task);
drainMicrotasks(); // 2. Microtask checkpoint
if (shouldRender()) { // 3. Render step (≈ every 16.6ms)
runRAFCallbacks(); // 3a. requestAnimationFrame
style(); // 3b. Recalculate styles
layout(); // 3c. Layout
paint(); // 3d. Paint
composite(); // 3e. Composite
}
if (idle()) {
runIdleCallbacks(); // 4. requestIdleCallback (if time permits)
}
}
requestAnimationFrame callbacks run inside the render step, before style/layout/paint. This is a fundamentally different timing guarantee than setTimeout:
setTimeout(fn, 0) | requestAnimationFrame(fn) | |
|---|---|---|
| When it runs | Next available task slot | Right before the next paint |
| Frequency | Uncontrolled | Once per frame (~60fps) |
| Synced to display | No | Yes |
| Background tabs | Throttled to 1/sec | Paused entirely |
| Purpose | General async scheduling | Visual updates |
rAF Timing Guarantees
One batch per frame
When the render step runs, the browser processes ALL currently queued rAF callbacks. But callbacks registered during a rAF callback run in the next frame:
requestAnimationFrame(() => {
console.log('Frame 1 - callback A');
requestAnimationFrame(() => {
console.log('Frame 2 - callback B');
});
});
requestAnimationFrame(() => {
console.log('Frame 1 - callback C');
});
Output across two frames:
- Frame 1:
Frame 1 - callback A,Frame 1 - callback C - Frame 2:
Frame 2 - callback B
The browser takes a snapshot of the rAF queue at the start of each render step. New callbacks added during processing are deferred to the next frame. This prevents the same infinite-loop problem that microtasks have.
The double-rAF trick
Because rAF callbacks within rAF are deferred, nesting two rAFs guarantees at least one paint between them:
element.classList.add('starting-position');
requestAnimationFrame(() => {
// First frame: starting-position is painted
requestAnimationFrame(() => {
// Second frame: now transition to the end
element.classList.add('ending-position');
});
});
This is essential for CSS transitions. The browser needs to paint the starting state before it can transition to the ending state. The double-rAF ensures that paint happens.
A common alternative to double-rAF is reading a layout property (like offsetHeight) to force a synchronous layout, then making the second change. This works but causes forced reflow — a performance cost. Double-rAF is cleaner because it works with the browser's natural rendering cycle rather than forcing it.
requestAnimationFrame for Smooth Animation
Now here's how you actually use rAF properly. The correct pattern uses a timestamp to calculate position, not fixed increments:
// Wrong: increment-based (speed varies with frame rate)
function animateBad() {
position += 2; // 2px per frame... but frames aren't consistent
element.style.transform = `translateX(${position}px)`;
requestAnimationFrame(animateBad);
}
// Right: time-based (consistent speed regardless of frame rate)
let startTime = null;
const duration = 1000; // 1 second
const distance = 300; // 300px
function animateGood(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1);
// Apply easing
const eased = easeOutCubic(progress);
element.style.transform = `translateX(${eased * distance}px)`;
if (progress < 1) {
requestAnimationFrame(animateGood);
}
}
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
requestAnimationFrame(animateGood);
The timestamp parameter is a DOMHighResTimeStamp — the same clock used by performance.now(). It's consistent and accurate to sub-millisecond precision.
requestIdleCallback: Non-Critical Work
And then there's the chill cousin of rAF. requestIdleCallback runs when the browser has idle time — after all tasks, microtasks, and rendering are done, and before the next frame is due.
requestIdleCallback((deadline) => {
// deadline.timeRemaining() tells you how much idle time is left
while (deadline.timeRemaining() > 0 && workQueue.length > 0) {
processItem(workQueue.shift());
}
// If there's more work, schedule another idle callback
if (workQueue.length > 0) {
requestIdleCallback(processMore);
}
});
The deadline object has:
timeRemaining()— milliseconds of idle time left (usually 0-50ms)didTimeout— whether the callback was called because its timeout expired
When to Use requestIdleCallback
- Analytics tracking and telemetry
- Pre-fetching resources for future navigation
- Indexing/processing data that isn't immediately needed
- Non-critical DOM updates (e.g., lazy-loading below-the-fold images)
- Cache warming
When NOT to Use It
- Anything the user is waiting for
- DOM changes that affect the current viewport
- Time-sensitive operations (it may run 50ms+ after you schedule it)
- Animations (use rAF)
// Good: send analytics during idle time
requestIdleCallback(() => {
sendAnalyticsEvent('page_view', { path: location.pathname });
});
// Bad: update visible UI during idle time
requestIdleCallback(() => {
element.textContent = 'Updated!'; // user might see a delayed pop-in
});
As of 2025, Safari does not support requestIdleCallback. Use a polyfill based on setTimeout for cross-browser compatibility: window.requestIdleCallback = window.requestIdleCallback || ((cb) => setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 1));. The polyfill doesn't provide real idle detection but prevents crashes.
Production Scenario: Virtualized List Prefetching
Here's a real-world use case that shows rIC at its best. A dashboard shows a table with 10,000 rows but only renders ~50 visible rows (virtualization). You want to pre-render rows that are just outside the viewport for smooth scrolling:
function prefetchNearbyRows(visibleRange) {
const { start, end } = visibleRange;
const prefetchRange = {
start: Math.max(0, start - 20),
end: Math.min(totalRows, end + 20),
};
// Schedule prefetching during idle time — don't block scrolling
requestIdleCallback((deadline) => {
let i = prefetchRange.start;
while (i < prefetchRange.end && deadline.timeRemaining() > 2) {
prerenderRow(i);
i++;
}
if (i < prefetchRange.end) {
// More rows to prefetch — schedule another idle callback
requestIdleCallback((dl) => prefetchBatch(i, prefetchRange.end, dl));
}
}, { timeout: 200 }); // Guarantee it runs within 200ms
}
The timeout option ensures the callback fires even if the browser never has idle time (e.g., during continuous scrolling). When didTimeout is true, timeRemaining() returns 0, so your code should do minimal work.
How rAF, rIC, and the Event Loop Interact
The exact order of timeout vs rAF depends on whether the browser schedules a render step before or after processing the timer task. In most browsers, the timer runs first, then rAF fires at the next paint. The key guarantee: sync is always first, promise always before any task or render callback.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Using setInterval for animations instead of requestAnimationFrame setInterval fires regardless of whether the browser is ready to paint. This wastes CPU, causes jank on variable-refresh displays, and drains battery in background tabs. | Always use rAF for visual updates. It syncs with the display refresh rate and pauses in background tabs. |
| Doing expensive computation inside requestAnimationFrame rAF runs inside the render step. If your rAF callback takes 10ms, the frame budget is blown and the user sees jank. Keep rAF callbacks under 4-5ms. | rAF callbacks should only do DOM writes and animation calculations. Heavy computation should happen before rAF in a task or worker. |
| Reading layout properties inside rAF after writing to the DOM If you write to the DOM (e.g., change a style) then read (e.g., read offsetHeight), the browser must run layout synchronously to return an accurate value. This is layout thrashing. | Batch all reads before writes. The read-then-write pattern inside rAF forces synchronous layout recalculation. |
| Using requestIdleCallback for time-sensitive work There's no guarantee when rIC fires. On a busy page, it might wait hundreds of milliseconds. Always provide a timeout if you need an upper bound. | rIC may be delayed indefinitely if the browser is busy. Use it only for non-critical, deferrable work. |
Challenge: rAF Ordering
Challenge: Animation Frame Timing
console.log('A');
requestAnimationFrame(() => {
console.log('B');
});
Promise.resolve().then(() => console.log('C'));
requestAnimationFrame(() => {
console.log('D');
});
setTimeout(() => console.log('E'), 0);
console.log('F');
Show Answer
Output: A, F, C, E, B, D (most likely) or A, F, C, B, D, E
The deterministic part:
AandFare synchronous — they run first. Output: A, FCis a microtask — drains after the script task. Output: A, F, C
The non-deterministic part depends on timing:
E(setTimeout) is a macrotaskBandD(rAF) run at the next render step
If the browser decides to render before processing the setTimeout task, the order is B, D, E. If the browser processes the setTimeout task first (no render needed yet), the order is E, B, D.
In practice, E usually runs before B, D because the browser typically doesn't need to render immediately after script evaluation. But this order is NOT guaranteed by the spec — it depends on when the browser schedules the render step.
The guaranteed ordering: A and F before C (sync before microtask). C before E (microtask before macrotask). B before D (rAF callbacks run in registration order). B and D are in the same frame.
Key Rules
- 1requestAnimationFrame runs INSIDE the render step, before style/layout/paint. It is not a macrotask or microtask — it's part of the rendering pipeline.
- 2rAF callbacks registered during a rAF callback run in the NEXT frame. This prevents infinite loops and enables the double-rAF pattern for guaranteed paint between operations.
- 3Use time-based animation (timestamp parameter) not increment-based. Increments produce inconsistent speed when frames drop or refresh rates differ.
- 4requestIdleCallback runs during idle periods after rendering. Use it for non-critical work (analytics, prefetching). Never use it for user-visible updates.
- 5rAF callbacks should stay under 4-5ms. If you blow the 16.6ms frame budget, the user sees jank. Move heavy computation to a Web Worker or chunk it across tasks.