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
scheduleIdleWorkcheckstypeof 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, passingtimeoutthrough so the browser is forced to run the callback within that window even under sustained load, rather than starving it indefinitely. runOnNextTickis the Safari fallback: asetTimeoutwith a fabricatedIdleDeadline. ItstimeRemaining()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
didTimeoutis alwaysfalsein the fallback — there's no real timeout mechanism being approximated, just a same-ticksetTimeout. Code that specifically branches ondidTimeoutwill 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-checktimeRemaining()between chunks instead of doing it all in one call. - Never use
requestIdleCallbackfor 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 thanrequestIdleCallbackever achieved.