In a browser, a function reschedules itself with setTimeout(step, 0) over and over. Why do the steps settle to roughly one every 4 ms instead of running back-to-back?
answer
- a counter, not a stopwatch
- depth of the timer chain matters
- five levels, then a floor
- a host rule, not ECMAScript
- only sub-4 ms delays are raised
basics
~20 sBrowsers apply the HTML standard's nesting clamp: once a timer chain runs more than five levels deep, a requested delay below 4 ms is raised to 4 ms. The engine is not lagging — the host is enforcing a floor.
solid answer
~50 sBecause browsers track a nesting level for timers. Each timer scheduled from inside a timer callback increments that counter, and the HTML standard says that once the nesting level is greater than five, a requested timeout below 4 ms is set to 4. So the first handful of iterations run fast, then the chain settles at about 4 ms per step — roughly 250 iterations per second, no matter what number you passed. The rule exists to stop runaway timer chains from pinning a CPU core and to bound how much a page can spin. It is a **host** rule, not an ECMAScript one: Node instead applies a floor of 1 ms to any sub-1 ms delay and has no equivalent nesting clamp. If you need to chunk work faster than that in a browser, driving it from a timer is the wrong tool.
code
javascript · 12 lineslet n = 0;
let last = performance.now();
function step() {
const now = performance.now();
console.log('step', n, 'gap', (now - last).toFixed(1), 'ms');
last = now;
if (++n < 10) setTimeout(step, 0);
}
setTimeout(step, 0);
// gaps near 1 ms for the first few steps, then about 4 ms in a browsergo deeper
Know that a browser will not honour very small timer delays inside a self-rescheduling chain, and that roughly 4 ms is the practical floor. Say plainly that the number you pass is a request, not a promise.
Explain the actual rule: a nesting level is carried by the scheduling context, and past depth five any delay under 4 ms becomes 4. Be able to predict where in a chain the gap changes and to say why the rule exists.
Show you can read the signature in production data — a flat, machine-independent 4 ms floor — and separate it from throttling or from genuinely slow work. Talk about sizing chunks by a time budget instead of by count.
Own the architectural call: timer-driven chunking has a hard throughput ceiling and competes with rendering and input. Argue for moving sustained computation off the main thread and for treating main-thread work budgets as a measured, enforced property of the app.
## The observation A self-rescheduling timer chain does not run as fast as the machine can go. Measure the gaps and you see something like this in a browser: ```js let n = 0; let last = performance.now(); function step() { const now = performance.now(); console.log(n, (now - last).toFixed(1)); last = now; if (++n < 10) setTimeout(step, 0); } setTimeout(step, 0); // 0 1.2 // 1 0.9 // 2 1.1 // 3 1.0 // 4 1.3 // 5 4.1 <- the clamp engages // 6 4.0 // 7 4.2 ``` The first few steps are quick; from the sixth onward the gap locks to roughly 4 ms. ## The mechanism: timer nesting level The HTML standard's timer initialisation steps keep a **nesting level** associated with the currently running timer callback. When you call `setTimeout` from ordinary code, the new timer's nesting level is one. When you call it from inside a timer callback, the new timer inherits that callback's level plus one — so a self-rescheduling chain counts 1, 2, 3, 4, 5, 6… The rule is then simply: *if the nesting level is greater than 5 and the requested timeout is less than 4, set the timeout to 4.* Two details follow directly from that wording: - **It is not about elapsed time or how fast you call it.** It is about how deep the chain is. A single `setTimeout(fn, 0)` from top-level code is not clamped. - **It only raises delays below 4.** If you already asked for 10 ms, the clamp changes nothing; you get your 10 ms (or more). Because the level is carried by the *scheduling context*, a chain that goes through a non-timer hop can reset it. Work driven by a promise chain or by a host callback other than a timer is not part of the timer nesting count. ## Why the rule exists Before it was standardised, `setTimeout(fn, 0)` chains were the ordinary way to write a busy loop that still let the browser breathe. Uncapped, such a chain schedules as fast as the loop can turn, which burns a core, drains battery, and starves rendering and input handling on a page that is nominally idle. A 4 ms floor at depth caps the damage at roughly 250 wake-ups per second per chain while leaving short, shallow uses — a single yield, a two-step deferral — unaffected. ## Host differences This clamp comes from the HTML specification, not from ECMAScript. The language spec has nothing to say about `setTimeout` at all; it defines run-to-completion and the job queue, and the host supplies timers. - **Browsers and browser workers:** the nesting clamp above, plus separate, much more aggressive throttling when a page is hidden. - **Node:** a delay of less than 1 is set to 1, applied to every call rather than only to nested ones. Node has no 4 ms nesting rule, so a self-rescheduling chain there runs closer to 1 ms per step. So a chunking loop tuned on a server can behave measurably differently in a page, which is worth saying out loud when the question is asked in a full-stack context. ## Practical consequences **Throughput ceiling.** If you split a job into N pieces and drive them with a nested `setTimeout(…, 0)`, the wall-clock floor in a browser is about `4 ms × N` once past depth five. Ten thousand pieces is forty seconds of pure scheduling overhead. Fewer, larger chunks — sized by a time budget measured with `performance.now()` rather than by a fixed item count — beat many tiny ones. **It is not a precision instrument.** People sometimes reach for a nested timer chain as a poll or a metronome. The clamp, plus the fact that the delay is only ever a minimum, makes it unsuitable for anything that must keep a rate. **Diagnosing it.** The signature is distinctive: gaps that start near 1 ms and snap to a flat 4 ms after five iterations, identical on a fast machine and a slow one. Flat, machine-independent timing means a rule is being applied, not that the code is slow. Contrast with throttling, where the floor jumps to about 1000 ms and only when the tab is in the background. The compressed answer: the browser counts how deep your timer chain is, and past level five it refuses to honour any delay under 4 ms.
- Does the clamp apply to the very first setTimeout(fn, 0) you call from top-level code?No. That timer's nesting level is one, well under the threshold, so a 0 ms request stays 0 (subject to normal queueing latency). The clamp only bites once a chain of timers scheduling timers has gone deeper than five levels — which is exactly the runaway case the rule was written to bound.
- If you need to chunk a long job in a browser, how do you avoid paying 4 ms per chunk?Do more work per chunk rather than more chunks. Size each slice by a time budget — keep working until `performance.now()` shows you have used, say, 5 ms, then yield once — so the per-yield cost is amortised over real work instead of dominating it. For genuinely heavy computation, move it off the main thread entirely rather than slicing it.
- How would you distinguish the 4 ms clamp from background-tab throttling when a timer is late?Look at the magnitude and the conditions. The clamp produces a flat ~4 ms floor, present regardless of tab visibility, and identical on fast and slow machines. Throttling produces a much larger floor — around a second in a hidden tab, dropping to about once a minute after several minutes hidden — and disappears the moment the tab is visible again.
saying these in an interview costs you the question
- Says the 4 ms floor applies to every setTimeout call
- Claims the clamp is defined by ECMAScript rather than the host
- Explains it as the engine being too slow to schedule faster
- Thinks passing 4 or more milliseconds avoids all host floors
- Confuses the nesting clamp with background-tab throttling