requestIdleCallback vs Worker Offloading
Both keep low-priority work from interfering with what the user is doing. One does it by waiting for gaps on the main thread; the other by using a different thread entirely. The choice turns on three questions — does the work touch the DOM, how long is it, and does it have to finish.
This comparison belongs to Task Scheduling & Prioritization, part of High-Performance Computation Patterns.
The Same Job, Both Ways
Building a search index over 40,000 records — pure computation, no DOM, roughly 900 ms of work.
// Idle-time version: runs in the gaps, on the main thread.
let cursor = 0;
const index = new SearchIndex();
function buildDuringIdle(deadline: IdleDeadline) {
// Work while the browser says there is room, in units small enough not to overrun.
while (cursor < records.length && deadline.timeRemaining() > 4) {
index.add(records[cursor++]);
}
if (cursor < records.length) {
requestIdleCallback(buildDuringIdle, { timeout: 10_000 });
} else {
onIndexReady(index);
}
}
requestIdleCallback(buildDuringIdle, { timeout: 10_000 });
// Worker version: runs on its own thread, uninterrupted.
const worker = new Worker(new URL('./index.worker.ts', import.meta.url), { type: 'module' });
worker.postMessage({ kind: 'BUILD', records }, [records.buffer]);
worker.addEventListener('message', (e) => { if (e.data.kind === 'READY') onIndexReady(e.data.index); });
The idle version never blocks a frame and may take twenty seconds of wall clock on a busy page — or never finish at all if the timeout is omitted and the page stays busy. The worker version finishes in about 900 ms regardless of what the page is doing, at the cost of a thread and a serialisation hop at each end.
What requestIdleCallback Actually Promises
It fires when the browser has spare time in a frame, and gives an IdleDeadline whose timeRemaining() starts at up to 50 ms. That ceiling is generous because idle periods can extend past a frame when nothing is scheduled.
It does not enforce anything. Overrunning the deadline blocks the frame like any other long task. The deadline is advice, and honouring it is your job.
Without timeout, it may never fire. A permanently busy page — animations, polling, a heavy third-party script — has no idle periods. The timeout option converts the callback into a “run by this time regardless” guarantee, at which point didTimeout is true and timeRemaining() is 0, meaning you are now a normal task and should do the minimum.
Hidden tabs get roughly one callback per 10 seconds in Chromium, and background tabs may be frozen entirely. Anything that must complete cannot rely on it.
// Handle the timeout branch explicitly — it is a different mode, not an edge case.
function work(deadline: IdleDeadline) {
const forced = deadline.didTimeout;
const budget = forced ? 4 : deadline.timeRemaining(); // be brief when forced
const stop = performance.now() + budget;
while (cursor < records.length && performance.now() < stop) index.add(records[cursor++]);
if (cursor < records.length) requestIdleCallback(work, { timeout: 10_000 });
}
The Decision Procedure
| Question | Idle callbacks | Worker |
|---|---|---|
| Does the work touch the DOM? | required — only option | impossible |
| Is it under ~50 ms in total? | yes, ideal | thread cost not worth it |
| Must it finish by a deadline? | only with timeout, and then it blocks |
yes, independent of page load |
| Is the input large binary data? | no copy needed | transfer it — cost is O(1) |
| Does it need a warm cache between runs? | shares the page’s memory | worker keeps its own, which survives navigation within the page |
| Should it survive a busy page? | no | yes |
| Is memory tight (low-end mobile)? | no extra cost | +2–5 MB per thread |
The short form: DOM work and short work go in idle time; long pure computation goes in a worker. The interesting cases are the ones in the middle, and two hybrid patterns cover most of them.
Idle-triggered offloading. Use an idle callback to start worker work, so thread construction and the initial message do not compete with page load. This is the recommended warm-up in Server-Side Rendering and Worker Hydration Pitfalls, and it costs one callback.
Worker computes, idle applies. The worker produces results; the main thread applies them to the DOM in idle slices. The heavy part is off-thread and the unavoidable DOM part never blocks a frame — the best available outcome for “render 8,000 rows of computed data”.
The Hybrid, Concretely
The “worker computes, idle applies” pairing is worth spelling out because it is the answer for most real features, and it is barely more code than either half alone.
// page.ts — heavy work off-thread, DOM work in idle slices
const rows: Row[] = await workerApi.buildRows(dataset); // 900 ms, another thread
let cursor = 0;
const fragment = document.createDocumentFragment();
function applyDuringIdle(deadline: IdleDeadline) {
while (cursor < rows.length && (deadline.timeRemaining() > 4 || deadline.didTimeout)) {
fragment.append(buildElement(rows[cursor++]));
if (cursor % 200 === 0) break; // bound one pass regardless of the deadline
}
container.append(fragment); // attach whole batches only
if (cursor < rows.length) requestIdleCallback(applyDuringIdle, { timeout: 500 });
}
requestIdleCallback(applyDuringIdle, { timeout: 500 });
The 500 ms timeout is deliberately short here, unlike the 10 s used for a background index: this work is visible, so the user should not wait indefinitely for an idle moment that a busy page may never provide. Timeout length is really a statement about how much the user cares, and choosing it per job is more useful than picking one constant for the application.
Deciding With Measurements Rather Than Rules
Two numbers settle the choice for any specific job, and both take minutes to collect.
Total CPU time of the job, measured with performance.now() around a synchronous run on a mid-tier device with 4× throttling. Under ~50 ms, stop — idle time is enough and a thread is overhead. Over ~200 ms, stop — it belongs in a worker. Between the two, the next number decides.
Idle availability of the page, measured by logging how much idle time actually exists:
let idleMs = 0;
const sample = (deadline: IdleDeadline) => {
idleMs += deadline.timeRemaining();
requestIdleCallback(sample);
};
requestIdleCallback(sample);
setTimeout(() => console.log(`idle: ${idleMs.toFixed(0)} ms in 10 s`), 10_000);
A page that accumulates several seconds of idle time in ten can host meaningful background work on the main thread. One that accumulates a few hundred milliseconds cannot, and any job scheduled there will finish late or not at all — which is a worker, or nothing.
Run that sampler on the real application with real content, not on a blank page. The difference between the two is usually an order of magnitude, and it is the reason idle scheduling works beautifully in a prototype and disappoints in production.
Gotchas
requestIdleCallback without a timeout on a page that is never idle. The work silently never happens, and nothing logs it. Always pass a timeout for anything that has to complete, and treat didTimeout as a distinct mode.
Treating timeRemaining() as a budget for a single indivisible call. If one index.add() takes 30 ms, checking the deadline before it does not help. Deadline checks only work when the units between checks are small.
Scheduling idle work from inside idle work. A callback that queues its own successor unconditionally can monopolise every idle period, starving other idle consumers on the page. Track total idle time consumed per job and stop when a budget is reached, rather than running until the work is done.
Safari support arrived late. requestIdleCallback shipped in Safari 17.4; older versions need a fallback, and the usual setTimeout(fn, 1) shim has none of the deadline semantics — it merely defers.
Idle callbacks that allocate heavily. Work performed in idle time still allocates on the page’s heap, so a garbage collection triggered by it lands on the main thread and can produce exactly the long task the scheduling was meant to avoid. A worker’s allocations are collected on the worker’s own thread, which is a quieter advantage of offloading than the CPU time and often the more visible one in a profile.
A worker for 20 ms of work. Construction plus two hops costs more than the work saved, and adds a thread’s memory to every session. Below roughly 50 ms, idle time wins.
Assuming a worker is unaffected by throttling. Hidden-tab policies reduce timer resolution inside workers too, and mobile engines may suspend a background tab entirely. Long jobs should checkpoint and resume on visibilitychange whichever mechanism they use.
Idle work competing with itself. Several features each scheduling their own idle loop will interleave unpredictably and each will progress more slowly than its author measured in isolation. One idle scheduler for the application, fed by a queue, is easier to reason about and easier to instrument.
Performance Note
Building the 40,000-record index on a 2023 laptop with a moderately busy page: the idle-callback version reached completion after 8.9 s of wall clock, using 900 ms of CPU spread over ~140 callbacks, and never produced a long task. The worker version completed in 1.05 s — 900 ms of compute plus 150 ms of transfer and construction — with 12 ms of total main-thread cost. On an idle page the idle version finished in 1.4 s, which is the honest best case and still slower than the thread that simply ran.