10LOC
Web Platformintermediate

requestIdleCallback for scheduling non-urgent work

Published March 23, 2027

type IdleCallback = (deadline: IdleDeadline) => void;

const runOnNextTick = (callback: IdleCallback) => {
  const start = performance.now();
  setTimeout(() => callback({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (performance.now() - start)) }), 1);
};

export const scheduleIdleWork = (callback: IdleCallback, timeout = 2_000) =>
  typeof requestIdleCallback === "function" ? requestIdleCallback(callback, { timeout }) : runOnNextTick(callback);

What

scheduleIdleWork runs a callback during the browser's idle periods — after it's finished layout, paint, and any pending input handling for the frame — using requestIdleCallback where it exists, and a setTimeout-based approximation where it doesn't.

Why it matters

Not every task needs to run now. Sending a batch of analytics events, prefetching the next likely page, or pruning an in-memory cache are all real work that can wait without the user noticing, but setTimeout(fn, 0) doesn't express "wait" — it just queues the callback for the next macrotask, competing with everything else regardless of whether the browser is actually free. requestIdleCallback is the API that means what it says: the browser calls your function only when it has genuine spare time in the frame, and hands you an IdleDeadline so you can check timeRemaining() and bail out before you'd cause a dropped frame.

The catch: Safari has never implemented requestIdleCallback, on desktop or iOS, and there's no indication it's coming. Any real usage needs a fallback, not just a feature check that does nothing when the API is missing.

How it works

  • scheduleIdleWork checks typeof requestIdleCallback === "function" rather than assuming it exists — the safe way to feature-detect a global that Safari genuinely doesn't define.
  • Where it exists, requestIdleCallback(callback, { timeout }) is used directly, passing timeout through so the browser is forced to run the callback within that window even under sustained load, rather than starving it indefinitely.
  • runOnNextTick is the Safari fallback: a setTimeout with a fabricated IdleDeadline. Its timeRemaining() returns time left against a fixed 50ms budget — the same budget real idle periods are typically capped at — measured from when the timeout actually fired, so callers that check it behave consistently whether they got a real or a fake deadline.
  • Consumers write the same code either way: scheduleIdleWork((deadline) => { while (deadline.timeRemaining() > 0 && workRemains()) doNextChunk(); }).

Gotchas

  • didTimeout is always false in the fallback — there's no real timeout mechanism being approximated, just a same-tick setTimeout. Code that specifically branches on didTimeout will behave slightly differently under the fallback.
  • An idle period is not a guarantee of a long stretch of free time — timeRemaining() can return a small number or drop to 0 mid-callback if the browser needs to handle input. Chunk real work and re-check timeRemaining() between chunks instead of doing it all in one call.
  • Never use requestIdleCallback for anything the user is waiting on. It's explicitly for work that's fine to delay indefinitely under load; there's no strong guarantee of when it runs, only that it won't block anything more urgent.

Related

  • queueMicrotask / Promise.resolve().then() — for work that must run soon and in order, the opposite end of the urgency spectrum from idle callbacks.
  • scheduler.postTask() — a newer, priority-based scheduling API ("background" priority covers similar ground) with broader intended browser support than requestIdleCallback ever achieved.