A dashboard batches analytics events and flushes them with requestIdleCallback(flush). On a busy page the flush does not run for many seconds. Why does that happen, and how do you bound the delay?
answer
- it only ever runs in leftover time
- nothing can interrupt a task in progress
- the options object has exactly one knob
- a boolean tells you it ran anyway
- timeout plus didTimeout
basics
~20 sIdle callbacks only run when the main thread has spare time before the next frame, so a page with back-to-back long tasks never produces an idle period. Pass a timeout in the options object — requestIdleCallback(flush, { timeout: 2000 }) — and the browser runs the callback anyway once it expires.
solid answer
~50 sAn idle callback is opportunistic, not scheduled. The browser only invokes it when it has finished the tasks and rendering it owes and still has time before the next frame is due, and the budget it hands you never exceeds 50 ms. On a dashboard that is parsing responses, re-rendering and running handlers back to back, there simply is no leftover time, and — critically — an idle callback cannot preempt a running task, so one 400 ms task pushes every pending idle callback past it. Starvation can last for as long as the page stays busy. The fix is the `timeout` option: `requestIdleCallback(flush, { timeout: 2000 })` promises the callback will run within roughly two seconds even if no idle period appears. When it fires that way, `deadline.didTimeout` is `true` and `timeRemaining()` is 0, so the work is now competing with the user — which is why a timed-out flush should do the minimum and defer the rest.
code
javascript · 18 linesconst pending = [];
let handle = 0;
function flush(deadline) {
const forced = deadline.didTimeout;
while (pending.length > 0 && (deadline.timeRemaining() > 1 || forced)) {
console.log('sending', pending.shift());
if (forced) break; // no budget: do the minimum, come back later
}
handle = pending.length > 0 ? requestIdleCallback(flush, { timeout: 2000 }) : 0;
}
function record(event) {
pending.push(event);
if (handle === 0) handle = requestIdleCallback(flush, { timeout: 2000 });
}
record({ type: 'click' });go deeper
Know that requestIdleCallback runs only when the browser has spare time, so on a busy page it may not run at all, and that passing { timeout: n } forces it to run within about n milliseconds.
Be ready to explain why long tasks starve idle callbacks — run-to-completion means nothing preempts a task, and idle callbacks are last in line behind tasks and rendering — and to branch on didTimeout inside the callback.
Expect to treat persistent starvation as a diagnosis rather than a scheduling bug: no idle periods means the main thread has no headroom, and every deferred subsystem on the page is affected the same way.
Own the policy question of how stale each class of deferred work may become, since the timeout value is a staleness contract, and decide which work must not be entrusted to opportunistic scheduling at all.
## Why the flush never runs `requestIdleCallback` is a request for *leftover* main-thread time. The browser's turn looks roughly like: run a task from some task source, run any rendering steps that are due, and then, if the thread is free and the next frame is not yet needed, run idle callbacks until the idle period ends. Two properties of that arrangement explain the symptom. First, **idle callbacks are last in line.** Input handlers, timers, network responses, and the rendering steps all outrank them. On a dashboard that is receiving a stream of updates, each arriving payload creates a task, each task may dirty style and layout, and the frame after it consumes what was left. There is nothing after. Second, **nothing preempts a running task.** JavaScript on the main thread runs to completion. If a chart library spends 400 ms laying out a series, the browser cannot interrupt it to service a pending idle callback; the callback waits until that task ends and then waits again for an idle period that the *next* queued task may consume first. This is the sense in which "long tasks starve idle callbacks": it is not a fairness policy, it is the absence of any gap to run in. A third factor is worth naming even though it is not the cause here: a hidden or backgrounded page has aggressive throttling applied to deferred work, so a flush scheduled with no timeout in a tab the user has switched away from may be delayed far longer than anything you would observe while watching the page. ## The timeout option ```js requestIdleCallback(flush, { timeout: 2000 }); ``` `timeout` is the only option the API takes. It converts an opportunistic request into a bounded one: if no idle period has occurred by the time it expires, the browser queues the callback to run regardless of how busy the thread is. Inside the callback you can tell the difference: ```js function flush(deadline) { if (deadline.didTimeout) { sendSmallestUsefulBatch(); // no budget; do the minimum } else { while (deadline.timeRemaining() > 1 && pending.length) sendOne(); } } ``` On a timed-out invocation `timeRemaining()` is 0. The browser has explicitly chosen your callback over responsiveness, so the honest response is to keep it short. Treating a timed-out callback as if it had a normal budget is how a well-intentioned deferral turns into the long task that drops a frame. ## Choosing the timeout The timeout is a statement about how stale the work may become, not a performance knob. For analytics batching, a couple of seconds is typical — long enough that a quiet moment usually arrives first, short enough that a user who navigates away has not lost much. Setting it to a very small value defeats the purpose: nearly every invocation becomes a timed-out one, and you have written a worse `setTimeout`. Setting it to nothing at all is what produced the bug. ## What a timeout does not solve The timeout bounds *when the callback starts*, not whether the work survives. If the user navigates or closes the tab, a pending idle callback is simply dropped, and so is one that started but had queued a normal request. Deferred delivery of data that must not be lost is a different problem with a different platform answer, and the batching scheduler should not be asked to solve it. Nor does a timeout make the work cheap. A flush that serializes ten thousand events is a long task whenever it runs; the scheduling decision only controls *when* you pay, and the work itself still has to be small enough — or chunked across several callbacks — to fit the budget you are given. ## The diagnosis in an interview If you are shown this symptom, say the two things in order: idle callbacks run only in leftover time and cannot interrupt a running task, therefore a continuously busy thread yields no leftover time; and the platform's answer is the `timeout` option, whose arrival you detect with `didTimeout` and respond to by doing less. Then add the caveat that a page which never goes idle is itself the finding — persistent starvation means the main thread has no headroom, which is a problem with the page, not with the scheduler.
- When the timeout fires and your callback finally runs, what does deadline.timeRemaining() report, and how should the callback behave?It reports 0 — the browser ran you despite not being idle, so there is no budget by definition. The callback should do the smallest useful amount of work and re-schedule the rest rather than draining the whole queue. Code that loops `while (deadline.timeRemaining() > 1)` will do nothing at all on a timed-out invocation, which is why the guard is usually written as `timeRemaining() > 1 || didTimeout`.
- If the page never goes idle at all, is adding a timeout the right fix?It is the right fix for the flush, but the starvation is itself the finding. A main thread with no leftover time between frames is one where input is already queuing behind long tasks, and every deferred subsystem on the page is suffering the same way. Add the timeout so the batch is bounded, then treat the absence of idle periods as the real defect to investigate.
- Does an idle callback with a timeout still fire if the user switches to another tab?Not on the same schedule. A hidden page has its deferred work throttled heavily, so both idle periods and timed-out invocations can be delayed well beyond what you would see in a foreground tab, and work scheduled at the moment of hiding may not run before the tab is discarded. Anything that must be delivered as the page goes away needs a mechanism designed for that, not an idle callback.
saying these in an interview costs you the question
- Thinks a pending idle callback can interrupt a long-running task
- Believes requestIdleCallback fires on a regular interval
- Doesn't know the timeout option exists
- Drains the whole queue on a timed-out invocation
- Uses a 50 ms timeout, making every call a timed-out one