The RuntimeApr 18, 2026·10 min read·

setTimeout(fn, 0) is never 0ms

Every FE dev knows setTimeout isn't instant. Almost nobody can quote the HTML Timers clamping rules, V8's timer backend, or explain why nested timeouts floor to 4ms. Here's the tour, primary sources only.

Run this:

setTimeout(fn, 0) — what delay do you actually get?js
1const start = performance.now();
2setTimeout(() => {
3console.log("Observed delay:", (performance.now() - start).toFixed(2), "ms");
4}, 0);

You'll get something between 1ms and 20ms, never 0. On a laptop at rest, probably 1–4ms. Inside a long task, maybe 50+. In a backgrounded tab, potentially 1000+.

setTimeout(fn, 0) isn't a bug. The 0 is a minimum wait, not a promise. This post walks through exactly what the browser does with that zero — from HTML's clamping rules, through V8's timer queue, to Chrome's background throttling policy (which is now a real production concern).

tl;dr

The HTML "timer initialization steps" clamp any timeout < 4ms to 4ms once the timer's nesting level > 5 (per spec) — so the first clamped timer, by the spec, is the one scheduled from inside 5 prior timers. Blink's implementation fires the clamp one level earlier (nesting level ≥ 5). Backgrounded tabs are additionally throttled to 1-per-second once nested; after 5 minutes hidden, "intensive throttling" further clamps to 1-per-minute. The "timeout" is a delay until queueing, not until execution — the callback still waits behind everything else on the event loop.

The primary source

Timers live in the WHATWG HTML "Timers" section. The clamping rule is part of the timer initialization steps:

If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.

Two conditions, both required:

  1. Nesting level > 5 — this timer is scheduled from inside at least 5 prior nested timers. "Greater than 5" is strict; level 6 is the first clamped nesting level per the spec.
  2. Timeout < 4ms — you asked for something below the floor.

Your first setTimeout(fn, 0) isn't clamped. Nesting has to accumulate.

Try it yourself

nested setTimeout(fn, 0) — watch for the first jump to ~4msjs
1let n = 0;
2let prev = performance.now();
3function next() {
4const now = performance.now();
5console.log("level " + n + ": " + (now - prev).toFixed(1) + "ms");
6prev = now;
7if (++n < 10) setTimeout(next, 0);
8}
9next();

Two things to notice when you run it:

  • The first one or two levels are usually well under 1ms (no clamp).
  • Somewhere around the 5th or 6th nested call in Chrome/Edge (Blink) you'll see delays jump to ~4ms — matching the spec's "if nesting level > 5, clamp to 4ms" rule (so the first clamped call is at nesting level 6). Firefox and Safari behave similarly. Your mileage may vary by browser; check the live trace rather than trusting a rule-of-thumb number.

The why behind the clamp is covered in the 2021 Chrome explainer Heavy throttling of chained JS timers. Tight setTimeout(0) loops used to be a dominant source of wake-locks and battery drain. Every browser converged on a 4ms floor to stop that pattern from livelocking the event loop. The HTML spec then codified what everyone already did.

What the timer actually is

Here's the mental model most devs have — and why it's slightly off:

"setTimeout(fn, 100) means fn runs 100ms from now."

Accurate model:

"setTimeout(fn, 100) means: after at least 100ms, the runtime queues a task to run fn. That task waits its turn in the macrotask queue. When the event loop picks it up, fn runs. Total observed delay = 100ms + queueing latency + task-run time of whatever was ahead of it."

Watch the canvas. When a setTimeout token moves from Web APIs to the macrotask queue, that's the timer resolving. The next motion — macrotask queue to call stack — is the event loop picking it up. The 100ms delay only covers the first leg.

If the main thread is busy when the timer resolves (a long task running, microtasks draining), your callback is queued but waits. That's why "I called setTimeout(fn, 16) and it ran at 83ms" isn't a bug. It's a backlog.

The V8 timer backend

In V8, the work of "hold onto this callback until the delay elapses, then fire it into the event loop" is delegated to the embedder — that's Chrome/Blink in the browser, libuv in Node.js. V8 itself doesn't wake up on timers.

In Chrome, scheduled timer callbacks go through Blink's scheduler. Each Document has a DOMTimerCoordinator, which stores pending timeouts and fires them when the OS wakes the process:

…/frame/dom_timer.ccc++
annotated · abridged
// ↓ author's note: reduced to the clamp path. Compare to spec's "> 5".
DOMTimer::DOMTimer(ExecutionContext* context, /* … */)
    : nesting_level_(context->Timers()->TimerNestingLevel() + 1) {
  if (nesting_level_ >= kMaxTimerNestingLevel &&
      timeout < kMinimumInterval) {
    timeout = kMinimumInterval;
  }
  StartOneShot(timeout, FROM_HERE);
}

The exact constant names and comparison operator have shifted in Blink over time — follow the link above and read the current definitions in dom_timer.cc before citing them. The behavioural rule is what the HTML spec guarantees: once nesting level exceeds 5, the timeout is raised to at least 4ms.

StartOneShot() hands the work off to base::TimerBase, which registers with the OS's timer subsystem (on Linux/macOS: timerfd / kqueue; on Windows: SetThreadpoolTimer). The OS wakes the renderer, Blink enqueues the callback onto the appropriate task queue, and the event loop picks it up on the next cycle.

Three things the spec + implementation tell you

1. Backgrounded tabs throttle, severely

The HTML spec allows user agents to extend timeouts "for any reason" — notably when the document isn't currently visible. Every major browser does this; the exact policy is browser-specific.

Chrome's policy, per Heavy throttling of chained JS timers and Intensive throttling of Javascript timer wake ups:

  • Tab hidden, chained timers (chain-count ≥ 5): those timers are grouped into a throttled bucket and the browser only checks the bucket once per second.
  • "Intensive" throttling engages when all three of these are true: the page has been hidden for > 5 minutes, chain count is ≥ 5, AND the page has been silent for at least 30 seconds. The bucket's check cadence then drops to once per minute. Timers don't "stop" — they align to minute boundaries, so the next wake-up might be up to 60 seconds later than you asked.

Click through the states — same setTimeout(fn, 50) call, four different realities:

timer cadence · by visibility stateinteractive
cadence50ms between fires
0500ms1s1.5s2s
setTimeout(fn, 50) fires roughly on time. No throttle applies.

This is why:

  • Analytics pings that rely on setTimeout miss events when users tab away.
  • WebSocket keep-alives can't be timer-driven in bg tabs.
  • Music/video players use Web Audio or Service Workers to stay alive.
gotcha

If your code must fire reliably while backgrounded, don't use setTimeout or setInterval. Use a Service Worker, Web Workers with audio/video keep-alive tricks, requestAnimationFrame (which stops cleanly when backgrounded and is therefore not a workaround — it's honesty), or for periodic pulls the Periodic Background Sync API.

2. setTimeout(fn, 0) vs queueMicrotask(fn) vs Promise.resolve().then(fn)

These three all defer a callback. They go to very different places:

SchedulerWhere it queuesWhen it runs
setTimeout(fn, 0)macrotask queue (after ≥0–4ms delay)next event-loop cycle, behind any queued tasks
queueMicrotask(fn)microtask queueduring current microtask checkpoint
Promise.resolve().then(fn)microtask queue (via PerformPromiseThen)during current microtask checkpoint

Don't just read it — step through it. The same script schedules one of each, and you can watch exactly which queue each lands in:

engine visualizertrace
Three ways to defer — three different queues
01/11Script starts. Running as a task.
program
console.log("1 sync");

setTimeout(() => console.log("5 macrotask"), 0);
queueMicrotask(() => console.log("3 microtask"));
Promise.resolve().then(() => console.log("4 microtask"));

console.log("2 sync");
expected → 1, 2, 3, 4, 5
Call StackLIFO
LIFO · executes now
<script>
Web APIsFIFO
timers · fetch · DOM
// empty
event loop
Microtask QueueFIFO
Promise · queueMicrotask
// empty
Macrotask QueueFIFO
setTimeout · I/O · UI
// empty
console0 lines
// (empty)
01/11

If you need to "run this after the browser has had a chance to paint", setTimeout(fn, 0) is your tool — it yields to the event loop, which then may run the render step of the processing model before picking up your callback. queueMicrotask does not yield. It stays within the current checkpoint, blocking any paint until the checkpoint drains (see The microtask checkpoint nobody explains).

3. setTimeout(fn, 0) is not the fastest "later"

MessageChannel postMessages schedule a task without going through setTimeout's timer path at all:

const { port1, port2 } = new MessageChannel();
port1.onmessage = () => doWork();
port2.postMessage(null);  // schedules a task — no 4ms nesting clamp.

The resulting task queue has no nesting clamp (the 4ms rule is specific to setTimeout/setInterval). It's still a task rather than a microtask, so the browser can paint between yields. That's the right primitive when you want "yield to the event loop, resume ASAP".

React's scheduler (packages/scheduler/src/forks/) uses this pattern: the default fork calls MessageChannel to post continuation work; a newer fork uses scheduler.postTask when available (see SchedulerPostTask.js). Either way, React doesn't use setTimeout(fn, 0) for scheduling — it avoids the 4ms clamp.

note

For user-space code, the 4ms clamp rarely matters. It's significant inside perf-critical libraries — scheduling, animation frameworks, virtual DOMs — where deep nesting is common and 4ms × N adds up. That's the context in which modern frameworks route around setTimeout(0) entirely.

The scheduler nobody talks about

There's a newer scheduler API that supersedes most setTimeout(0) use cases:

scheduler.postTask(doWork, { priority: "user-visible" });

Three priorities (user-blocking, user-visible, background) map to distinct Blink task queues with different throttling policies. No 4ms clamp. Priority-aware yielding. Backgrounded-tab policies you can reason about.

It's gated behind the Prioritized Task Scheduling API — shipping in Chromium since 2022, polyfillable for others. If you're writing library code in 2026, reach for this before setTimeout. The performance profile is better, the semantics are clearer, the cross-browser support is "good enough" with a tiny polyfill.

Why this matters in production

Three patterns you'll recognize from real codebases, all symptoms of what we've covered:

  • Animations that jump instead of tween — deeply nested setTimeout(..., 16) is actually firing on an effective max(16, 4 × N) cadence once clamped, creating micro-stutter. Fix: requestAnimationFrame, which is already aligned to the display's vsync.
  • Analytics beacons that drop events during tab-awaysetTimeout(sendBeacon, 500) queued as the user tabs away. Tab goes background; timer is throttled into a bucket that wakes once a second; unload fires before the timer resolves. Fix: navigator.sendBeacon() is designed for this and is not timer-backed.
  • Dashboards that freeze for hundreds of ms after every fetch — callback-hell of setTimeout(() => setTimeout(...)) for sequential async work pays the clamp on every nested call. Fix: Promise-chain or async/await (see What await desugars to) — microtask-scheduled, no clamps, cleaner call stacks for DevTools.

Recap

  • setTimeout(fn, 0) schedules a task, not a synchronous call. Observable delay = ~1ms on a fast machine when unnested; rises to ≥ 4ms once Blink's nesting_level_ >= 5 clamp engages.
  • Spec vs. implementation: HTML says clamp when nesting > 5, Blink implements >=. One-level difference; the direction is the same.
  • Backgrounded tabs are aggressively throttled: 1-per-second for chained timers once the tab is hidden, 1-per-minute after 5 minutes hidden. If reliability matters, reach for navigator.sendBeacon, Service Workers, or Background Sync.
  • MessageChannel / scheduler.postTask are clamp-free alternatives for library-level scheduling — that's what React's scheduler uses.
  • The "wait" in setTimeout is until queueing, not until execution — the callback still waits behind everything else on the event loop.

Next in The Runtime series: "Jobs vs. tasks: ECMA-262's Job Queue is not what you think." We'll look at the boundary between language-level Jobs (ECMA-262) and host-level tasks (HTML event loop), and why it's the root cause of almost every "but in Node.js…" async bug.


Primary sources: