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.

How the same 900 ms job progresses under each strategy Two timelines over ten seconds of a moderately busy page. The idle-callback strategy makes progress only in the gaps between the page's own tasks, so the job advances in short irregular bursts and completes at about nine seconds, with no frame ever blocked. The worker strategy runs continuously on its own thread and completes at about one second, while the main thread's own tasks are unaffected; the only main-thread costs are a short construction block at the start and a short result-handling block at the end. 900 ms of index building on a busy page idle callbacks done ≈ 9 s olive = index work in an idle gap · brown = the page's own tasks · no frame ever blocked worker worker thread — continuous, done ≈ 1 s pumpkin = the only main-thread cost: construction at the start, result handling at the end Neither line blocks a frame. They differ by nine seconds in when the feature becomes available.
Idle scheduling optimises for never interfering; a worker optimises for finishing. Those are different goals, and the wrong one produces a feature that is technically smooth and practically absent.

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”.

Decision tree for background work A decision tree with three questions. First: does the work touch the DOM? If yes, it must run on the main thread in idle slices; if it also contains a computational core, split that core into a worker. If no, the second question asks whether the work exceeds about fifty milliseconds. If it does not, idle callbacks are sufficient and a thread is not worth its construction cost. If it does, the third question asks whether the result must be available by a deadline. If yes, use a worker so progress does not depend on the page being idle. If no, either works, and a worker is still preferred when the input is large binary data that can be transferred. Three questions, in this order Touches the DOM? yes no idle slices, main thread split any computational core out into a worker Longer than ~50 ms? yes no worker finishes regardless of how busy the page is idle callbacks a thread is not worth 5–15 ms and 2–5 MB
The first question is not a preference — DOM access decides the thread, and everything else is optimisation within that constraint.

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.


What each mechanism guarantees about completion Three completion guarantees. An idle callback with no timeout may never run on a busy page. An idle callback with a timeout runs by the deadline, but at that point it is an ordinary task and blocks like one. A worker completes independently of how busy the page is, at the cost of a thread and two serialisation hops. What you are actually promised about finishing requestIdleCallback with no timeout may never run — a busy page has no idle periods at all requestIdleCallback with a timeout runs by the deadline, and then blocks like any other task a worker finishes regardless of the page, for a thread and two hops Only the third row makes a promise that survives a busy page.
Choosing between them is choosing which guarantee the feature needs, not which API is more modern.

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.

Frequently Asked Questions

Can I trust deadline.timeRemaining() to keep me under the frame budget?
Only for the code you control. timeRemaining() reports how long the browser believes it can let you run — up to 50 ms when idle — and it counts down as you work, but nothing enforces it. A single synchronous call that overruns blocks the frame exactly as it would anywhere else. Check the deadline between units of work and size those units so one cannot overrun.
If a worker is always available, why use requestIdleCallback at all?
Because a worker cannot touch the DOM, and because starting one costs 5–15 ms and 2–5 MB. Work that needs DOM access, or that is too small to justify a thread, is better done in idle time. Idle callbacks are also the natural trigger for starting a worker, so the two are frequently used together rather than as alternatives.

See also