Engineering Diaries · JavaScript
§
JavaScriptBrowser InternalsPerformance

setTimeout(fn, 0) Is Not Zero. In A Background Tab, Your 100ms Timer Runs Once A Minute.

The delay you pass to setTimeout is a minimum. Measured in Chromium, Firefox, WebKit and Node: the 4ms nesting clamp, hidden tabs throttled to 1 second, Chrome's once-a-minute intensive throttling after 5 minutes, a 30-day timer that fires instantly, and what to use instead.

01 · The Experiment

I Hid A Tab And Went To Make Chai

The page was about forty lines long. It ran one loop: setTimeout(tick, 100), and inside tick, record the time and schedule the next one. Ten ticks a second. Next to it, a setInterval at 100ms doing the same thing, and a Web Worker running the same chained timer on its own thread.

I let it run in the foreground for ten seconds, opened another tab on top of it, and left it hidden for a little over seven minutes. Then I switched back and read the timestamps.

In the foreground, both loops ticked every 100ms, as asked. Less than a second after the tab went into the background, both dropped to exactly one tick per second. That held for five minutes. Then, at the five-minute mark, the gaps jumped to 53 seconds, then 60 seconds. Over seven minutes and twenty-one seconds hidden, a timer that should have fired about 4,400 times fired 302 times. The Web Worker's timer, meanwhile, kept ticking every 100ms the whole time, as if nothing had happened.

None of this is a bug. Every piece of it is either in the HTML spec or in a documented browser policy. But I have reviewed a lot of frontend code that treats the second argument to setTimeout as a promise, and it is not one. It is the shortest time the browser will wait. Everything above that number is the browser's call, and the browser has opinions.

On a payroll and HR platform, plenty of screens are long-lived. Someone starts a long job, switches to their email, and comes back fifteen minutes later expecting the progress bar, the "your session expires in 2:00" warning and the auto-save indicator to all be telling the truth. Every one of those is usually a timer. So I wanted to stop guessing and measure what timers actually do - in the foreground, in a hidden tab, and at the edges of the delay argument itself.

The delay you pass to setTimeout is a floor, not a schedule. Everything above it is the browser's decision.

Everything below comes from scripts I ran with Playwright 1.59.1 against Chromium 147, Firefox 148 and WebKit 26.4, plus Node 26. Where a claim comes from documentation instead of my own run, I say so. This is also a companion to my piece on Long Animation Frames - that one is about why the main thread is busy, this one is about what happens to the work you scheduled for later.

02 · The Contract

The Delay Is "At Least", And The Spec Says So

The HTML spec's timer steps do not say "run the callback after timeout milliseconds". They say to wait until at least that long has passed, and then queue a task. Queueing a task means the callback joins the line behind whatever else the event loop has to do. If the main thread is busy, it waits.

That part most people know in theory. Here is what it looks like in practice. I scheduled a setTimeout(fn, 0) and then blocked the main thread with a 200ms busy loop:

probe.js
const t0 = performance.now();
setTimeout(() => console.log(performance.now() - t0), 0);

const end = performance.now() + 200;
while (performance.now() < end) {}

// Chromium: 200.2   Firefox: 200   WebKit: 200

A "zero" timeout that took 200 milliseconds, in every engine. Not because the timer was slow, but because there was nobody free to run it. That is the first way the number lies: the delay is measured from when you call setTimeout, but the callback only runs when the thread is free. If you have a long task, your timer is late by however long that task runs. This is exactly the delay that the Long Animation Frames API attributes to scripts, and it is why a perfectly reasonable 16ms animation timer can produce a janky 80ms frame.

But even with an idle thread, the browser adds time of its own on purpose. There are three separate mechanisms, and they stack:

  • Nesting clamp. Timers scheduled from inside timers, deep enough, get a 4ms floor. Applies everywhere, visible tab or not.
  • Background throttling. When the tab is hidden, timers are batched so they fire at most about once a second.
  • Intensive throttling. In Chrome, after a tab has been hidden for five minutes, chained timers are batched to once a minute.

And then there is a fourth thing that is not throttling at all, but an integer overflow, and it goes in the opposite direction. We will get to that.

03 · The 4ms Clamp

The First Few Are Free. Then Every Hop Costs You 4ms

The spec keeps a timer nesting level. A timer scheduled from normal code starts at zero. A timer scheduled from inside a timer callback is one level deeper. Once the nesting level goes past 5, any timeout under 4ms is raised to 4ms. This clamp is old - a historical compromise to stop pages that spin on setTimeout(fn, 0) loops from pinning the CPU - and it survived into the modern spec.

I ran a chain of twelve nested setTimeout(fn, 0) calls and recorded the gap between each callback:

EngineFirst clamped callbackGap once clamped100 chained setTimeout(0)
Chromium 1477th~5ms497ms
Firefox 1485th4-6ms576ms
WebKit 26.411th7-10ms800ms
Node 26No nesting clamp~1ms124ms

Two things surprised me here. The first is that the engines do not agree on when the clamp starts. Chromium let six callbacks through untouched, Firefox clamped from the fifth, and the Playwright WebKit build held off until the eleventh. MDN describes the clamp as kicking in "once a nested call to setTimeout has been scheduled 5 times", which matches Firefox exactly and is close enough for the others. The second is that "4ms" was rarely 4ms. On my Mac, Chromium's clamped gaps sat around 5ms and WebKit's were often double the spec number. The spec gives a floor; the operating system's timer resolution sits on top of it.

Node is the odd one out. It has no nesting clamp, but it turns a delay of 0 into 1, so every hop still costs about a millisecond. That is why 100 hops took 124ms there, against 3.4ms for 100 hops of setImmediate.

497ms
100 chained setTimeout(0) in Chromium
0.3ms
Same 100 hops over MessageChannel
800ms
100 chained setTimeout(0) in WebKit
~1,600x
Chromium's gap between the two

Where this actually hurts

Nobody writes a 100-deep timer chain on purpose. They write this:

utils/processInChunks.js
function processInChunks(items, fn, size = 50) {
  let i = 0;
  (function next() {
    const end = Math.min(i + size, items.length);
    for (; i < end; i++) fn(items[i]);
    if (i < items.length) setTimeout(next, 0); // "yield to the browser"
  })();
}

It is the textbook way to break up a long task, and it is a good instinct. But run it over 10,000 rows with chunks of 50 and you have 200 hops. Each one costs real work plus a clamped 4-5ms that does nothing. That is roughly a second of the user waiting on idle time in Chromium, and closer to two seconds in WebKit. The cost scales with the number of chunks, so the instinct to use smaller chunks for smoother UI makes it worse.

04 · Receipts

React's Scheduler Already Refuses To Use setTimeout

If you want proof that this clamp is real enough to design around, read React's scheduler. When it needs to hand control back to the browser and resume work in a fresh task, it does not use setTimeout. Here is the relevant part of packages/scheduler/src/forks/Scheduler.js on React's main branch today:

react/packages/scheduler/src/forks/Scheduler.js
if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

That comment - "We prefer MessageChannel because of the 4ms setTimeout clamping" - is the whole argument in one line. React's concurrent rendering yields constantly, every few milliseconds. If every yield paid a 4ms tax, time-slicing would spend more time waiting than rendering. So the scheduler posts a message to itself instead. A postMessage on a MessageChannel queues a task immediately, with no nesting level and no clamp.

I measured the difference with the same 100-hop chain. Chromium: 497ms with setTimeout, 0.3ms with MessageChannel. Firefox: 576ms against 2ms. WebKit: 800ms against 8ms. Each hop is still a real task, so the browser can still paint and handle input between them. You just stop paying for nothing.

This is not a React trick

The same idea shows up in older code as window.postMessage to yourself, and it is why polyfills for setImmediate used to exist. React is just the most widely deployed piece of code that writes the reason down in a comment.

05 · Hidden Tabs

Hide The Tab And 100ms Becomes 1,000ms Within A Second

Browsers have throttled timers in background tabs for over a decade, because a hidden tab running a 16ms animation loop is pure battery drain with nobody watching. MDN documents a one-second minimum in inactive tabs for Firefox, and Chrome documents the same once-per-second check for hidden pages. What I wanted to know was how quickly it kicks in, and what it does to code that assumes the timer is on time.

The answer to the first question is: almost immediately. Here is what my 100ms timers did, bucketed by how long the tab had been hidden:

Tab stateChained setTimeout(100)setInterval(100)Worker setTimeout(100)
Visible~102ms~100ms~107ms
Hidden, first 10s1,000ms1,000ms~102ms
Hidden, 10s to 5 min1,000ms1,000ms~102ms
Hidden, past 5 min53,001ms, 59,962ms53,001ms, 59,962ms~102ms

The first one-second gap appeared 917ms after the tab was hidden. There was no grace period. And note that setInterval behaved exactly like the chained setTimeout. People sometimes reach for setInterval believing it is "more accurate" because it does not drift. In a hidden tab it gets batched just the same - each iteration counts as part of the chain.

One second sounds harmless until you look at what people put in timers. A typing-indicator debounce, a "saving..." spinner that hides after 300ms, a retry with a 200ms backoff, a progress poll every 500ms. All of those run at 1Hz the moment the user switches tabs. Most of the time nobody notices, because nobody is looking. The problems show up when the user comes back and the code has been counting ticks instead of reading the clock.

The countdown bug

A session-timeout warning that does secondsLeft -= 1 every second is accurate in a visible tab. In a hidden tab past five minutes it decrements once a minute, so the warning on return can be off by many minutes - and the server-side session may already be gone while the UI cheerfully says "4:12 remaining".

06 · Intensive Throttling

After Five Minutes, Chrome Checks Your Timers Once A Minute

Chrome 88, in January 2021, added a third level on top of the one-second throttle. The Chrome team calls it intensive throttling, and their write-up lists four conditions that must all be true:

  • The page has been hidden for more than 5 minutes.
  • The timer's chain count is 5 or greater - it was scheduled from inside a timer callback, five deep, or it is a setInterval that has already run a few times.
  • The page has been silent for at least 30 seconds. Playing audio exempts you; a silent audio track does not count.
  • WebRTC is not in use.

When all four hold, Chrome checks those timers once per minute, aligned to the minute. That is exactly what my run showed. The one-second ticks stopped at five minutes, the next callback came 53 seconds later - landing on the minute boundary - and the one after that 59,962ms later. Then I brought the tab back and the timer went straight back to 100ms.

What happened to one setTimeout(tick, 100) loop
Visible: every 100ms
Hidden: every 1s
Hidden 5 min+: every 60s

Read the conditions again and think about which code meets all four. A polling loop is the textbook case: it is chained by definition, and a dashboard that polls for job status is usually silent and not on a video call. So is a heartbeat that keeps a session alive, a "refresh the token 60 seconds before it expires" timer that was rescheduled a few times, and almost every hand-rolled countdown. Chrome's own post is blunt about the alternative for state polling: use events rather than timers - WebSockets, server-sent events, or the various observers.

The Worker result

The Web Worker running the same chained 100ms timer in the same hidden tab was never throttled in my run - not at one second, not at five minutes. That is interesting, and I want to be careful with it. The Chrome write-up does not promise anything about workers, so this is observed behaviour in one Chromium version, not a contract. It is also exactly the kind of battery cost the throttling exists to prevent. If you genuinely need a heartbeat to keep going in a hidden tab, a worker is a reasonable place to look. If you are moving a UI countdown into a worker to dodge throttling, you are solving the wrong problem: the user cannot see the countdown anyway, so compute it correctly when they come back.

The real takeawayA hidden tab is not paused, and it is not running normally either. It is running on the browser's schedule. Code that is correct only if timers are punctual is code that is correct only while someone is watching.
07 · The Overflow

A 30-Day Timer Fires Immediately. In Every Engine.

Now the opposite problem. Throttling makes timers late. This one makes them early - catastrophically early.

The delay argument to setTimeout is converted to a signed 32-bit integer. The largest value that fits is 2147483647 milliseconds, which is about 24.8 days. Anything bigger wraps around. If the wrapped value is negative, the browser treats it as zero, and the callback runs on the next turn of the event loop.

I tested a handful of delays in all three engines, giving each one 1.5 seconds to fire:

Delay passed inChromiumFirefoxWebKit
2 ** 31 - 1 (24.8 days)Did not fireDid not fireDid not fire
2 ** 31 (one ms more)0ms0ms0ms
30 days0ms0ms0ms
2 ** 32 + 10001,002ms1,004ms1,002ms
"1000" (a string)1,002ms1,001ms1,002ms

All three engines agree, because they all follow the same WebIDL conversion. One millisecond past 24.8 days and your timer fires now. Go past 232 and it wraps all the way around to a small positive number, so a delay of about 49.7 days fires after one second. The last row is a small bonus: strings get converted to numbers, so setTimeout(fn, "1000") works, which is a fun way to learn that an environment variable was never parsed.

Node behaves differently and, I think, better. It prints a warning and sets the delay to 1ms:

node 26
setTimeout(fn, 30 * 24 * 3600 * 1000);
// (node) TimeoutOverflowWarning: 2592000000 does not fit into a 32-bit signed integer.
// Timeout duration was set to 1.
// ...and fn runs 1.5ms later

Still immediate, but at least it tells you. Browsers say nothing.

Who actually passes 25 days to setTimeout?

Nobody types it. Code computes it. The classic shape is a token refresh scheduled off an expiry claim:

auth/scheduleRefresh.js
const msUntilRefresh = token.exp * 1000 - Date.now() - 60_000;
setTimeout(refreshToken, msUntilRefresh);

That is fine for a one-hour access token. Now someone changes the backend so that "remember me" sessions get a 30-day token. The refresh fires immediately. If the refresh handler schedules the next refresh using the new token's expiry - which is also 30 days - it fires immediately again. You have written a loop that hits your auth server as fast as the network allows, from every tab of every user who ticked the box. The same shape appears in "remind me later" banners, subscription-expiry nudges and anything keyed off an end date.

The fix is a few lines. Never trust a computed delay to fit; re-arm until the deadline actually arrives:

utils/setLongTimeout.js
const MAX_DELAY = 2 ** 31 - 1;

export function setLongTimeout(fn, ms) {
  const deadline = Date.now() + ms;
  let id;
  (function arm() {
    const left = deadline - Date.now();
    if (left <= 0) return fn();
    id = setTimeout(arm, Math.min(left, MAX_DELAY));
  })();
  return () => clearTimeout(id);
}

It checks the wall clock on every wake-up instead of trusting the timer to have counted correctly, which also makes it honest about laptop sleep. A sleeping machine does not run timers, and depending on the platform the time asleep may or may not count towards your delay. Comparing against Date.now() sidesteps the question.

08 · What To Use Instead

Pick The Primitive By What You Actually Want

Most timer bugs come from using one tool for four different jobs. setTimeout gets used to yield, to poll, to count down and to schedule something far in the future, and it is only really good at "do this once, a bit later, and I do not mind if it is late". Here is how I split them now.

To yield to the browser: scheduler.yield, with a MessageChannel fallback

scheduler.yield() gives control back to the browser and resumes your work with priority over other queued tasks, so a long loop does not end up behind every other script on the page. It shipped in Chrome 129 and Firefox 142. scheduler.postTask has been in Chrome since 94 and also landed in Firefox 142. Safari has neither, and the Playwright WebKit build I tested did not expose them either. So in practice:

utils/yieldToMain.js
export function yieldToMain() {
  if (globalThis.scheduler?.yield) return scheduler.yield();
  return new Promise((resolve) => {
    const { port1, port2 } = new MessageChannel();
    port1.onmessage = () => { port1.close(); resolve(); };
    port2.postMessage(null);
  });
}

// for (const chunk of chunks) { work(chunk); await yieldToMain(); }

In my runs, 100 hops of scheduler.yield() took 0.2ms in Chromium and 1ms in Firefox. The fallback is the MessageChannel path that React uses. One honest caveat: the fallback does not get yield's priority boost, so on a busy page your continuation waits its turn behind other tasks. It is still nothing like a 4ms floor.

To poll: stop polling when nobody is looking

If the data matters while the tab is hidden, have the server push it - server-sent events and WebSocket messages arrive as events, not timers, which is what Chrome recommends. If polling is fine, make it visibility-aware: stop when hidden, and fetch immediately when the user comes back, because that is the moment they actually look.

utils/visiblePoller.js
export function pollWhileVisible(fn, ms) {
  let id;
  const start = () => { fn(); id = setInterval(fn, ms); };
  const stop = () => clearInterval(id);
  const onChange = () => (document.hidden ? stop() : start());

  document.addEventListener("visibilitychange", onChange);
  if (!document.hidden) start();
  return () => { stop(); document.removeEventListener("visibilitychange", onChange); };
}

Data libraries already do the "refetch on return" half of this - TanStack Query's refetchOnWindowFocus is on by default - but a hand-written setInterval in a useEffect usually is not.

To count down: compute, do not count

A countdown that does remaining -= 1 on every tick is wrong the moment a tick is late, and in a hidden tab ticks are late by design. Store the deadline, and on every render compute deadline - Date.now(). Late ticks then just mean a less frequent update, not a wrong number. Re-render on visibilitychange so the user sees the correct value the instant they come back.

You want to...UseAvoid
Break up a long taskscheduler.yield(), MessageChannel fallbacksetTimeout(fn, 0) chains
Poll a serverPush (SSE, WebSocket), or poll only while visibleFixed setInterval forever
Show a countdownDeadline + Date.now(), refresh on visibilitychangeDecrementing a counter per tick
Run something days latersetLongTimeout, or do it on the serverRaw setTimeout with a computed delay
AnimaterequestAnimationFrame or CSSsetTimeout(fn, 16)
09 · Your Tests Cannot See This

Playwright Turns Background Throttling Off By Default

Here is the part that made me want to write this up. My first attempt at the hidden-tab experiment showed no throttling at all. The tab I had covered with another tab still reported document.visibilityState === "visible", and its 100ms timer kept firing every 100ms.

That is not Chromium being lenient. It is Playwright, on purpose. In playwright-core/lib/server/chromium/chromiumSwitches.js, the default Chromium flags include:

playwright-core 1.59.1 - chromiumSwitches.js
"--disable-background-timer-throttling",
"--disable-renderer-backgrounding",
// ...and crPage.js turns on Emulation.setFocusEmulationEnabled for every page

That is a sensible default for a test runner. Tests open several pages, and you do not want the one that is not in front to slow down and make your suite flaky. But it means that if your session-timeout warning only breaks in a hidden tab, no Playwright test you write with default settings will ever catch it. Even removing those two flags was not enough in my run, because of the focus emulation - every page Playwright controls keeps saying it is visible. I had to launch the same Chromium binary myself and drive it over the raw DevTools protocol to get a tab that was genuinely hidden.

So test the behaviour, not the browser. Two things work well:

  • Use fake timers (vi.useFakeTimers(), jest.useFakeTimers(), or Playwright's page.clock) and assert that your countdown shows the right value after jumping forward a full minute in one step. If your code counts ticks instead of reading the clock, that test fails.
  • Dispatch a visibilitychange event yourself in a unit test and assert that the poller stops and restarts. That covers the logic without needing a real hidden tab.
10 · The Short Version

Treat Every Timer As A Suggestion

  • The delay is a minimum. A busy main thread turned a 0ms timeout into 200ms in every engine I tested.
  • Nested timers get a 4ms floor after a handful of hops. 100 chained setTimeout(0) took 497ms in Chromium, 576ms in Firefox and 800ms in WebKit. The same hops over MessageChannel took 0.3ms, 2ms and 8ms.
  • Hide the tab and timers fire at most once a second. In my Chromium run, a 100ms timer hit 1,000ms gaps 917ms after the tab went into the background - setInterval included.
  • After five hidden minutes, Chrome's intensive throttling checks chained timers once a minute. Measured: 53s, then 60s. 302 callbacks in 7m21s instead of about 4,400. A timer in a Web Worker was not throttled at all in the same run.
  • Delays above 2,147,483,647ms (24.8 days) overflow. A 30-day setTimeout fired immediately in Chromium, Firefox and WebKit. Node warns and runs it after 1ms. Clamp and re-arm.
  • Yield with scheduler.yield() and a MessageChannel fallback. Count down from a deadline. Poll only while visible, or better, let the server push.
  • Playwright disables background throttling and fakes focus by default. Your e2e suite cannot see any of this, so cover it with fake clocks and visibilitychange tests.

None of these behaviours are new. The 4ms clamp is older than most of the frameworks we use, and Chrome's one-minute throttling shipped in Chrome 88 in early 2021. They keep catching people because the API looks so simple. Two arguments, a function and a number. The number just never meant what it looks like it means.

If your code would break when a timer fires a minute late, it is already broken. You just have not left the tab open long enough.

Foreground timing, MessageChannel, scheduler and overflow results were reproduced locally with Playwright 1.59.1 against Chromium 147.0.7727.15, Firefox 148.0.2 and WebKit 26.4, headless, on macOS (Apple Silicon) on 8 October 2026; WebKit numbers are from Playwright's WebKit build, not a shipping Safari. Node results are from Node 26.0.0. The hidden-tab run used the same Chromium 147 binary launched directly and driven over the raw DevTools protocol (no Playwright emulation), with the page hidden for 7 minutes 21 seconds behind a foreground tab; one run, so treat exact gap values as illustrative. Intensive throttling conditions are from the Chrome for Developers post "Heavy throttling of chained JS timers beginning in Chrome 88". Clamp and overflow descriptions are from the HTML timer initialisation steps and MDN's setTimeout page. Scheduler API support is from MDN browser-compat-data. The React snippet is from packages/scheduler/src/forks/Scheduler.js on React's main branch, lightly trimmed. Playwright's default flags are from playwright-core 1.59.1.