You're building a dashboard. A chart animates every second, a notification badge pulses, and there's analytics tracking firing in the background. Which timer do you use for what?
I've seen too many codebases throw setTimeout at everything and wonder why their UI stutters. The browser gives you two scheduling APIs purpose-built for different jobs: requestAnimationFrame for visual work and requestIdleCallback for background work. Here's when to use each — and why mixing them up tanks your frame rate.
The Core Difference
requestAnimationFrame (rAF) runs your callback right before the browser paints. It's synced to the display's refresh rate (usually 60Hz). Use it for anything that changes what the user sees.
requestIdleCallback (rIC) runs your callback when the browser has nothing else to do. It gives you a deadline — how long until the next frame needs to start. Use it for anything the user doesn't need to see right now.
// Visual work — synced to paint
requestAnimationFrame((timestamp) => {
element.style.transform = `translateX(${timestamp % 500}px)`;
});
// Background work — runs when idle
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && hasWork()) {
processNextItem();
}
});The key insight: rAF is frame-guaranteed. rAF callbacks always fire before the next paint. rIC callbacks might not fire at all if the main thread is busy — and that's the point. Non-critical work should yield to user interactions.
When to Use requestAnimationFrame
Animations
This is what rAF was built for. CSS transitions cover simple cases, but when you need JS-driven animation — physics, spring dynamics, data-driven transitions — rAF is the only right answer.
function animateScroll(element, target, duration) {
const start = element.scrollTop;
const distance = target - start;
const startTime = performance.now();
function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease-out cubic
const eased = 1 - Math.pow(1 - progress, 3);
element.scrollTop = start + distance * eased;
if (progress < 1) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}Why not setInterval(fn, 16)? Because:
- rAF pauses when the tab is hidden (saves CPU/battery)
- rAF syncs to the actual refresh rate (some displays are 120Hz)
- rAF avoids frame drops from timer drift
DOM Measurements Before Paint
Need to read layout properties (offsetHeight, getBoundingClientRect) before the browser paints? Do it in rAF to batch reads and writes, avoiding layout thrashing.
// Bad — forces layout recalc on every iteration
items.forEach(item => {
const height = item.offsetHeight; // read (forces layout)
item.style.height = height * 2 + 'px'; // write (invalidates layout)
});
// Good — batch reads, then batch writes
const heights = items.map(item => item.offsetHeight); // reads
requestAnimationFrame(() => {
items.forEach((item, i) => {
item.style.height = heights[i] * 2 + 'px'; // writes
});
});Canvas Rendering
Game loops, chart redraws, particle systems — anything that redraws a <canvas> should go through rAF.
function gameLoop(timestamp) {
updatePhysics(timestamp);
clearCanvas();
drawScene();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);When to Use requestIdleCallback
Analytics and Tracking
You just tracked a page view, a click, and a scroll depth event. None of these need to fire right now. The user won't notice if they fire 200ms later.
const analyticsQueue = [];
function trackEvent(name, data) {
analyticsQueue.push({ name, data, timestamp: Date.now() });
if (!isProcessing) {
requestIdleCallback(processQueue);
}
}
let isProcessing = false;
function processQueue(deadline) {
isProcessing = true;
while (deadline.timeRemaining() > 0 && analyticsQueue.length > 0) {
const event = analyticsQueue.shift();
navigator.sendBeacon('/api/events', JSON.stringify(event));
}
if (analyticsQueue.length > 0) {
requestIdleCallback(processQueue);
} else {
isProcessing = false;
}
}Prefetching and Preloading
You know the user will probably navigate to /dashboard next. Load the data now — but only when the browser is idle.
function prefetchRouteData(route) {
requestIdleCallback(() => {
fetch(`/api${route}`, { priority: 'low' })
.then(res => res.json())
.then(data => cache.set(route, data))
.catch(() => {}); // Silent fail — it's just a prefetch
});
}Non-Critical DOM Updates
Updating a "last synced 2 minutes ago" label, rendering a tooltip on hover (after a delay), or hydrating below-the-fold components — all perfect for rIC.
// Hydrate below-the-fold components when idle
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
requestIdleCallback(() => {
hydrateComponent(entry.target);
});
observer.unobserve(entry.target);
}
});
});Large Data Processing (Chunked)
Parsing a 5MB JSON file? Don't block the main thread for 300ms. Split it into chunks processed during idle time.
function processLargeArray(items, processFn, onComplete) {
let index = 0;
function processChunk(deadline) {
while (index < items.length && deadline.timeRemaining() > 1) {
processFn(items[index]);
index++;
}
if (index < items.length) {
requestIdleCallback(processChunk);
} else {
onComplete();
}
}
requestIdleCallback(processChunk);
}
// Usage: filter 10k items without blocking the UI
processLargeArray(
allProducts,
(item) => { if (matchesFilter(item)) filtered.push(item); },
() => renderResults(filtered)
);The Deadline Object
rIC gives you a deadline object with two key properties:
| Property | Type | Description |
|---|---|---|
timeRemaining() | function | Milliseconds left until the browser needs to yield. Starts at ~50ms max. |
didTimeout | boolean | true if the callback fired because the timeout was reached, not because the browser was idle. |
Always check deadline.timeRemaining() before doing more work. And check deadline.didTimeout — if your callback timed out, you might want to do less work to avoid jank.
requestIdleCallback((deadline) => {
// If we timed out, do minimum work to avoid blocking the frame
const maxItems = deadline.didTimeout ? 1 : 10;
for (let i = 0; i < maxItems && queue.length > 0; i++) {
if (deadline.timeRemaining() <= 0) break;
processNext(queue);
}
if (queue.length > 0) {
requestIdleCallback(processQueue);
}
}, { timeout: 2000 }); // Force-run after 2s even if not idleThe timeout option is useful when your work eventually needs to happen. Without it, rIC might not fire for seconds on a busy page.
Comparison Table
| Feature | requestAnimationFrame | requestIdleCallback |
|---|---|---|
| Purpose | Visual updates, animations | Non-critical background work |
| Timing | Before next paint | When main thread is idle |
| Guaranteed execution | Yes — every frame | No — deferred when busy |
| Paused when tab hidden | Yes | Yes |
| Deadline info | No (use performance.now()) | Yes (deadline.timeRemaining()) |
| Typical budget | ~16ms (60Hz frame) | ~50ms max per idle period |
| Callback arg | DOMHighResTimeStamp | IdleDeadline |
| Browser support | All modern browsers | All except IE (Safari added in 15.4) |
Common Mistakes
Using rAF for non-visual work
// Bad — analytics doesn't need to sync with paint
requestAnimationFrame(() => {
trackPageView(); // This doesn't touch the DOM
});This wastes frame budget. If the browser is already struggling to hit 60fps, your analytics callback is stealing time from layout and paint. Use rIC instead.
Using rIC for visual work
// Bad — animation that stutters
requestIdleCallback(() => {
element.style.transform = `translateX(${x}px)`;
});rIC callbacks might run at unpredictable intervals. Your animation will look choppy because updates aren't synced to the display refresh.
Ignoring the deadline
// Bad — processes everything even if out of time
requestIdleCallback(() => {
heavyList.forEach(item => processItem(item)); // Could block for 100ms+
});Always respect deadline.timeRemaining(). The whole point of rIC is that it yields to more important work.
Not handling Safari fallback
Safari didn't support rIC until version 15.4 (March 2022). If you need broader support:
const scheduleIdle = typeof requestIdleCallback !== 'undefined'
? requestIdleCallback
: (cb) => setTimeout(cb, 1);A Real-World Pattern: Dashboard With Both
Here's how you'd combine both APIs in a real app — a dashboard with animated charts and background data syncing:
class Dashboard {
constructor() {
this.charts = [];
this.syncQueue = [];
}
// Visual: animate chart transitions with rAF
updateCharts(newData) {
const startValues = this.charts.map(c => c.value);
const startTime = performance.now();
const animate = (now) => {
const t = Math.min((now - startTime) / 300, 1);
this.charts.forEach((chart, i) => {
chart.value = startValues[i] + (newData[i] - startValues[i]) * t;
chart.render();
});
if (t < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}
// Background: sync local changes when idle
queueSync(change) {
this.syncQueue.push(change);
this.scheduleSync();
}
scheduleSync() {
if (this._syncScheduled) return;
this._syncScheduled = true;
requestIdleCallback((deadline) => {
this._syncScheduled = false;
while (this.syncQueue.length > 0 && deadline.timeRemaining() > 2) {
const change = this.syncQueue.shift();
fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(change),
}).catch(() => this.syncQueue.push(change)); // Re-queue on failure
}
if (this.syncQueue.length > 0) this.scheduleSync();
}, { timeout: 5000 });
}
}Charts animate smoothly because they use rAF. Syncing happens in idle gaps without interrupting the animations. Both APIs do what they're designed for.
Quick Reference
Use requestAnimationFrame when:
- Animating DOM elements, canvas, or WebGL
- Reading layout properties before a paint (batching reads/writes)
- Running a game loop or visual simulation
- You need frame-synced execution
Use requestIdleCallback when:
- Sending analytics events
- Prefetching data or preloading resources
- Processing large datasets in chunks
- Updating non-critical UI ("last updated" labels, hydration)
- Doing anything the user won't miss if it's delayed by 100ms+
Key takeaway: rAF is for what the user sees. rIC is for what the user doesn't see. When in doubt, ask: "Will delaying this callback by 200ms be noticeable?" If yes, rAF. If no, rIC.