requestAnimationFrame and requestIdleCallback in JavaScript
requestAnimationFrame schedules a callback right before the browser repaints the next frame. requestIdleCallback defers work to idle time, after input handling and rendering are done.
Theory
TL;DR
- RAF fires roughly 60 times per second, synced to the display refresh rate (vsync). RIC fires sporadically, only when the browser has nothing more important to do.
- Analogy: RAF is a painter who calls "brush ready?" before each stroke. RIC is the janitor who cleans only after the painter finishes the whole room.
- Main difference: RAF ties to the repaint cycle (urgent visuals); RIC waits until after input and layout (background tasks).
- Animation or UI update? Use RAF. Analytics, logging, or prefetch? Use RIC.
- RIC has no Safari support, so always add a
{ timeout: ms }fallback.
Quick example
const box = document.querySelector('#box');
// RAF: fires before every repaint, ~60fps
function animate(time) {
box.style.transform = `translateX(${time * 0.05}px)`;
requestAnimationFrame(animate); // schedule next frame
}
requestAnimationFrame(animate);
// RIC: fires only during idle slices
function doIdleWork(deadline) {
while (deadline.timeRemaining() > 0) {
console.log('background task'); // respects remaining time budget
}
requestIdleCallback(doIdleWork, { timeout: 200 }); // retry with timeout fallback
}
requestIdleCallback(doIdleWork, { timeout: 200 });RAF passes a high-res performance.now() timestamp to the callback. RIC passes a deadline object so your code knows how much idle time remains before the browser needs the thread back.
Key difference
RAF callbacks fire during the browser's "begin frame" phase, before style recalculation, layout, paint, and compositing. They are synchronized to the GPU vsync signal, typically at 16.7ms intervals on a 60Hz display. RIC callbacks sit behind everything else in the browser's task scheduler and only execute in the leftover time slice after a frame has fully rendered. That slice is usually 4-10ms and can be zero on a busy page.
When to use
- DOM animations, canvas drawing, or anything that must stay smooth at 60fps: RAF.
- Scroll or resize responses that touch layout: RAF.
- Analytics pings or telemetry after hydration: RIC.
- Background sync queues or non-blocking prefetch: RIC.
- Tab goes to background: RAF pauses automatically; RIC also throttles.
- Mobile or low-power devices: RIC with
{ timeout: 500 }so the callback is never skipped entirely.
Comparison table
| Feature | requestAnimationFrame | requestIdleCallback |
|---|---|---|
| Trigger | Before next repaint (~16ms at 60Hz) | After main frame tasks (variable idle slice) |
| Priority | High (visual updates) | Low (background) |
| Callback params | callback(timestamp) | callback(deadline, options) |
| Cancel | cancelAnimationFrame(id) | cancelIdleCallback(id) |
| Browser support | All modern, IE10+ | Chrome/Edge 60+, Firefox 87+. No Safari. |
| Fallback | N/A | { timeout: ms } forces run after delay |
| Typical use | Three.js render loop, Phaser.js particles | Vercel Analytics pings, Workbox background sync |
How the browser handles this
In Chromium's rendering pipeline, RAF callbacks are queued on the main thread and fired during the "begin frame" event before rasterization, synced to the GPU vsync signal. This is why they stay smooth even at 120Hz: the browser calls them twice as often. RIC is handled by IdleTaskController, which measures how much time remains after style, layout, and compositing, then allocates that leftover budget via deadline.timeRemaining(). If a page never goes idle (heavy ad scripts, a game loop), RIC callbacks may not run at all without a timeout option.
Common mistakes
Mistake: using setInterval inside a RAF loop
// Wrong: two separate scheduling queues, causes dropped frames
function badAnimate() {
setInterval(() => { box.style.left = x++ + 'px'; }, 16);
requestAnimationFrame(badAnimate);
}Intervals run on a separate timer queue and do not sync to vsync. You get double scheduling and dropped frames. Use only recursive RAF.
Mistake: ignoring deadline.timeRemaining() in RIC
// Wrong: may run 100ms, blocks next paint
requestIdleCallback(() => {
heavyComputation();
});
// Right: slice the work
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 1 && tasks.length) {
processOneTask(tasks.shift());
}
});Overshooting the idle slot delays the next input response or paint. Break the work into slices that fit inside the available budget.
Mistake: no cancel on component unmount
// Wrong: id is lost, loop never stops
useEffect(() => {
requestAnimationFrame(animate);
}, []);
// Right: store id, cancel on cleanup
useEffect(() => {
const id = requestAnimationFrame(animate);
return () => cancelAnimationFrame(id);
}, []);Phantom RAF loops keep consuming CPU after a component is removed from the DOM. Always store the id and cancel in the cleanup function.
Mistake: RIC without timeout on mobile
Low-power mode and background tabs may never produce an idle window. { timeout: 500 } tells the browser to run the callback anyway after 500ms if idle never comes. Without it, analytics code can stall silently on mobile devices.
Real-world usage
- Three.js:
renderer.setAnimationLoopis a thin wrapper around RAF. - React Reanimated:
withSpringand gesture drivers use RAF loops for frame-accurate updates. - Preact Signals: RAF batches DOM commits to avoid multiple repaints per tick.
- Vercel Analytics: RIC defers pageview pings until after page hydration is done.
- Workbox (Google PWA library): RIC drives background sync queues between fetches.
Follow-up questions
Q: Why does RAF receive a timestamp as its argument?
A: It is a high-resolution performance.now() value. You use it to calculate delta = time - lastTime so animations move at the same speed regardless of frame rate.
Q: What happens to RAF when the tab is backgrounded?
A: The browser pauses it or throttles it to roughly 1fps to save CPU and battery. A plain setTimeout would keep firing at full rate.
Q: What is the deadline object in RIC?
A: It has two members: timeRemaining() returns how many milliseconds are left in the current idle slice, and didTimeout is true if the timeout option forced the callback to run before an idle window appeared.
Q: How do you polyfill RIC in Safari?
A: The simplest polyfill wraps setTimeout with a short delay (usually 0-1ms). A more accurate version checks performance.now() against the last known busy period and defers accordingly.
Q: On a 120Hz display with heavy JS, how does RAF interact with the compositor thread?
A: RAF callbacks run on the main thread before style and layout. But applying will-change: transform or transform3d to an element promotes it to a compositor layer. Animations on that layer are handled off the main thread entirely, which is why they stay smooth even when JS is blocking. The senior answer: promote to compositor layer via will-change so painting bypasses the main thread bottleneck.
Examples
Smooth animated counter in React
import { useRef, useEffect } from 'react';
function AnimatedCounter({ target }) {
const countRef = useRef(0);
useEffect(() => {
let rafId;
const animate = (time) => {
// Move 5% of remaining distance each frame for natural easing
countRef.current += (target - countRef.current) * 0.05;
document.getElementById('counter').textContent = Math.round(countRef.current);
if (Math.abs(target - countRef.current) > 0.5) {
rafId = requestAnimationFrame(animate);
}
};
rafId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafId); // cancel on unmount
}, [target]);
return <span id="counter">0</span>;
}Each RAF call nudges the counter 5% closer to the target. The animation eases naturally without any CSS transitions. Canceling on unmount prevents a phantom loop after the component is removed from the DOM.
Batching analytics with RIC and timeout fallback
const analyticsQueue = [];
function flushAnalytics(deadline) {
// Process events only while idle time remains
while (deadline.timeRemaining() > 1 && analyticsQueue.length > 0) {
const event = analyticsQueue.shift();
sendToServer(event); // non-blocking fetch
}
// If work remains, schedule the next idle slot
if (analyticsQueue.length > 0) {
requestIdleCallback(flushAnalytics, { timeout: 300 });
}
}
function trackEvent(name, data) {
analyticsQueue.push({ name, data, ts: performance.now() });
requestIdleCallback(flushAnalytics, { timeout: 300 });
}{ timeout: 300 } guarantees the queue is flushed within 300ms even if the page stays busy. I have seen this pattern in production dashboards where analytics were silently dropping on mobile before the timeout option was added. Without it, RIC callbacks can queue up indefinitely on heavy pages.
Frame-rate independent game loop (Three.js style)
const clock = { last: 0 };
function gameLoop(time) {
const delta = time - clock.last; // milliseconds since last frame
clock.last = time;
updatePhysics(delta); // move objects proportional to elapsed time
updateCamera(delta);
renderer.render(scene, camera);
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);Using delta makes movement frame-rate independent. At 60fps, delta is roughly 16ms. At 120fps it is roughly 8ms. The object travels the same distance per second in both cases, which matters when your users have different hardware.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.