Do the Boring Work When the Browser Is Bored: requestIdleCallback
Main-thread time is the scarcest resource in frontend work. Every millisecond you spend preparing analytics or warming caches is a millisecond a tap or keypress waits. The browser knows when it's idle - requestIdleCallback lets you borrow exactly those moments.
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && tasks.length) {
tasks.shift()(); // run small tasks until the idle window closes
}
if (tasks.length) scheduleMore();
});
The deadline object is the clever part: timeRemaining() tells you how many milliseconds of idle time are left (capped at 50ms to keep the page responsive to unexpected input). You do work while there's budget, then yield.
A reusable idle queue
The pattern above generalizes into ten lines I've carried between projects:
const idleQueue = {
tasks: [],
push(fn) {
this.tasks.push(fn);
this.schedule();
},
schedule() {
if (this.scheduled || !this.tasks.length) return;
this.scheduled = true;
requestIdleCallback((d) => {
this.scheduled = false;
while (d.timeRemaining() > 1 && this.tasks.length) this.tasks.shift()();
this.schedule(); // more tasks left? grab the next idle window
});
},
};
// Anywhere in the app:
idleQueue.push(() => prefetchRoute('/checkout'));
idleQueue.push(() => warmImageCache(nextProductImages));
idleQueue.push(() => compressAndStoreDraft(editorState));
Keep each task small (a few ms). If one task might be long, chunk it - the whole point is fitting into gaps.
The timeout escape hatch
Pure idle scheduling means "maybe never" on a busy page. If the work must eventually happen, say so:
requestIdleCallback(sendQueuedAnalytics, { timeout: 2000 });
If no idle window shows up within 2 seconds, the callback runs anyway (with deadline.didTimeout === true). Idle-first, deadline-guaranteed.
The Safari problem
Safari took ages to ship this API - it only arrived recently, so check support and fall back:
const onIdle = window.requestIdleCallback
?? ((fn) => setTimeout(() => fn({ timeRemaining: () => 8, didTimeout: false }), 200));
The setTimeout fallback isn't truly idle-aware, but a 200ms delay past load keeps you out of the critical path in practice.
Rule of thumb I use: if the user didn't ask for it and won't notice a two-second delay, it belongs in the idle queue. For work triggered by scrolling into view instead, IntersectionObserver is the right scheduler.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.