A colleague made a long job responsive by calling setTimeout(next, 0) after every single item. Each item takes microseconds, yet the job now processes only a few hundred items per second. What is going on, and how would you fix it?
answer
- the yield costs more than the work
- zero is not really zero
- nested timers hit a millisecond floor
- a few hundred hops per second
- batch by time, not per item
basics
~20 sA zero delay is not zero. Browsers clamp deeply nested zero-delay timers to about 4 ms and Node raises a delay below 1 ms to 1 ms, so a per-item timer chain is capped at a few hundred to a thousand hops per second. Batch by a time budget and yield once per batch instead.
solid answer
~50 sThe cost is the yield, not the work. Once a chain of timers is nested several levels deep, browsers clamp the requested zero delay to roughly 4 ms, and Node clamps any delay below 1 ms up to 1 ms; there is also per-task scheduling overhead on top. Chaining one timer per item therefore puts a hard floor of milliseconds on each item, so throughput collapses to the timer rate regardless of how cheap the item is — roughly 250 items per second in a browser. The fix is to decouple yield frequency from item count: run a `while` loop that keeps processing until a few milliseconds of budget are spent, then schedule the next batch once. That is thousands of items per hop and the same responsiveness, because what matters is how long each batch blocks, not how many items it covers.
code
javascript · 24 lines// Slow: one timer hop per item, clamped to milliseconds each
function perItem(items, work, done) {
let i = 0;
(function step() {
if (i >= items.length) return done();
work(items[i]);
i++;
setTimeout(step, 0);
})();
}
// Fast: many items per hop, bounded by a time budget
function batched(items, work, done) {
let i = 0;
(function step() {
const start = Date.now();
while (i < items.length && Date.now() - start < 5) {
work(items[i]);
i++;
}
if (i < items.length) setTimeout(step, 0);
else done();
})();
}go deeper
Know that a zero delay is a minimum, not an instant callback, so scheduling one timer per item makes the job pay a real delay per item.
Give the mechanism and the numbers: nested zero-delay timers clamp to about 4 ms in browsers and 1 ms in Node, so per-item chaining caps throughput at the timer rate regardless of item cost.
Diagnose it from evidence — elapsed time insensitive to item cost, gaps dwarfing batches — and fix it by decoupling yield frequency from item count with a measured time budget.
Frame the real question: whether this work belongs on the interactive thread at all, and what responsiveness and throughput budgets the team commits to before anyone hand-tunes a chunk size.
## The arithmetic Start with the numbers, because they make the diagnosis obvious. One item costs, say, 5 microseconds. One `setTimeout(next, 0)` hop costs at least the host's clamped minimum. In browsers, the HTML standard has each nested timer increment a nesting level, and once that level passes five, a requested delay below 4 ms is raised to 4 ms. A self-rescheduling chain is nested by construction, so from the sixth hop onward every hop costs about 4 ms. In Node, `setTimeout` with a delay below 1 ms is treated as 1 ms, so the same chain runs at roughly 1000 hops per second at best. One item per hop therefore gives about 250 items per second in a browser, or under 1000 in Node. The work is 0.1% of the elapsed time; the scheduling is the rest. Two hundred thousand items would take over ten minutes. ## Why the clamp exists It is a deliberate protection against exactly this pattern. Without a floor, a chained zero-delay timer is a busy loop with extra steps: it monopolises the thread while pretending to yield, and it drains battery on idle pages. Hosts impose the minimum so that a badly written chain degrades into something slow rather than something that starves everything else. It is worth being precise that this is host behaviour, not language behaviour — ECMAScript defines no timers at all. The clamp is in the HTML standard for browsers and in Node's own timer implementation. ## The fix: decouple yield frequency from work granularity The misconception behind per-item scheduling is that yielding more often means more responsive. It does not, past a point. Responsiveness is determined by the **longest** uninterrupted batch, not by the number of batches. A 5 ms batch keeps the thread available often enough for anything interactive; going finer than that buys nothing perceivable and pays a millisecond-scale toll every time. ```js function run(items, work, done) { let i = 0; (function step() { const start = Date.now(); while (i < items.length && Date.now() - start < 5) { work(items[i]); i++; } if (i < items.length) setTimeout(step, 0); else done(); })(); } ``` With 5 microsecond items, one 5 ms batch covers roughly a thousand items, so 200,000 items need about 200 hops — under a second of scheduling overhead instead of ten minutes. ## Why a time budget beats a fixed batch size A fixed count like "1000 items per batch" fixes the throughput problem but reintroduces the responsiveness problem on slow hardware or expensive rows: if items turn out to cost 200 microseconds, that batch blocks for 200 ms. Measuring elapsed time bounds what you actually care about — the blocking window — and lets the item count float. A subtlety worth mentioning: check the clock every iteration only if the check is cheap relative to the work. For extremely cheap items, `Date.now()` per iteration can itself dominate; check every N iterations instead. ```js while (i < items.length) { work(items[i]); i++; if ((i & 255) === 0 && Date.now() - start >= 5) break; } ``` ## How you would actually diagnose this in production The signature is a job whose elapsed time is a near-perfect multiple of the item count and completely insensitive to how expensive the items are. Make the work per item ten times cheaper and the job takes exactly as long — that alone identifies scheduling, not computation, as the bottleneck. Confirm it by instrumenting: log the wall time of a batch versus the gap between batches. If the gap dwarfs the batch, the yield is the cost. A second, sneakier symptom: the job runs at a *different* speed in Node than in a browser tab, and slower again in a background tab, because the clamping and throttling policies differ per host and per visibility state. Computation does not vary that way. ## What the fix does not solve Batching restores throughput and keeps responsiveness, but the work is still on the same thread, so the job still competes with everything else and still adds latency to whatever the user does while it runs. If the total is genuinely large, the honest answer may be that the algorithm should change — do less work, do it incrementally as data arrives, or precompute — rather than schedule the same work more cleverly.
- Does the same collapse happen in Node, or is this a browser-only problem?It happens in both, with different constants. Node raises any `setTimeout` delay below 1 ms to 1 ms, so a per-item chain tops out near a thousand hops per second plus loop overhead — better than the browser's roughly 250, still catastrophic for a large job. The diagnosis and the fix are identical: batch the work and yield once per batch.
- How would you convince yourself that scheduling, not computation, is the bottleneck?Change the cost of the work and watch the total. If making each item ten times cheaper leaves the elapsed time unchanged, the item cost is irrelevant and the hops dominate. Confirm by instrumenting a batch: record the wall time spent inside a batch against the gap until the next one. A gap that dwarfs the batch is the yield being paid for.
- If yielding more often does not improve responsiveness, what actually determines it?The duration of the longest uninterrupted batch. That is the worst-case wait before a queued click or keystroke can be handled, so keeping every batch to a few milliseconds is what matters. The number of batches is irrelevant to the user and only adds scheduling cost, which is why yield frequency should be driven by a time budget rather than by item count.
saying these in an interview costs you the question
- Believes setTimeout with delay 0 really means zero delay
- Assumes yielding more often always improves responsiveness
- Blames the item work when throughput is insensitive to it
- Fixes it with a fixed batch size tuned on one machine
- Thinks the clamp is a language rule rather than host behaviour