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.
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.
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:
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.
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:
| Engine | First clamped callback | Gap once clamped | 100 chained setTimeout(0) |
|---|---|---|---|
| Chromium 147 | 7th | ~5ms | 497ms |
| Firefox 148 | 5th | 4-6ms | 576ms |
| WebKit 26.4 | 11th | 7-10ms | 800ms |
| Node 26 | No nesting clamp | ~1ms | 124ms |
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.
Where this actually hurts
Nobody writes a 100-deep timer chain on purpose. They write this:
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.
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:
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.
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.
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 state | Chained setTimeout(100) | setInterval(100) | Worker setTimeout(100) |
|---|---|---|---|
| Visible | ~102ms | ~100ms | ~107ms |
| Hidden, first 10s | 1,000ms | 1,000ms | ~102ms |
| Hidden, 10s to 5 min | 1,000ms | 1,000ms | ~102ms |
| Hidden, past 5 min | 53,001ms, 59,962ms | 53,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.
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".
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
setIntervalthat 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.
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.
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 in | Chromium | Firefox | WebKit |
|---|---|---|---|
2 ** 31 - 1 (24.8 days) | Did not fire | Did not fire | Did not fire |
2 ** 31 (one ms more) | 0ms | 0ms | 0ms |
| 30 days | 0ms | 0ms | 0ms |
2 ** 32 + 1000 | 1,002ms | 1,004ms | 1,002ms |
"1000" (a string) | 1,002ms | 1,001ms | 1,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:
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:
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:
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.
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:
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.
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... | Use | Avoid |
|---|---|---|
| Break up a long task | scheduler.yield(), MessageChannel fallback | setTimeout(fn, 0) chains |
| Poll a server | Push (SSE, WebSocket), or poll only while visible | Fixed setInterval forever |
| Show a countdown | Deadline + Date.now(), refresh on visibilitychange | Decrementing a counter per tick |
| Run something days later | setLongTimeout, or do it on the server | Raw setTimeout with a computed delay |
| Animate | requestAnimationFrame or CSS | setTimeout(fn, 16) |
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:
"--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'spage.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
visibilitychangeevent yourself in a unit test and assert that the poller stops and restarts. That covers the logic without needing a real hidden tab.
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 overMessageChanneltook 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 -
setIntervalincluded. - 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
setTimeoutfired 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
visibilitychangetests.
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.
Related essays
- JavaScript · Browser Internals
JavaScript Reads "2026-10-06" As UTC And "2026-10-06T00:00" As Local Time. Most Off-By-One Date Bugs Start There.
new Date("2026-10-06") is UTC midnight, new Date("2026-10-06T00:00") is local midnight. Tested in Chromium, Firefox and WebKit across three time zones: where the day slips, why Indian teams rarely see it, "06-10-2026" becoming 10 June, DST arithmetic, and what Temporal fixes.
- Browser Internals · Performance
Chrome Stopped Running Your unload Handler. If Your Analytics Still Use It, Your Numbers Already Changed.
Since Chrome 154, unload handlers are off by default on every Chrome page load - they register without error and never run. Reproduced in Chromium: what it does to the Back button, which data quietly went missing during the seven-month rollout, and the pagehide pattern to use instead.
- Vite · Frontend Infrastructure
We Blamed Vite For A 90-Second Local Load. The Real Bottleneck Was The 11,516 Modules We Asked It For.
A local Vite page took 90 seconds to load because eager route imports pulled 11,516 modules into one page load. Making routes lazy cut it to 54 modules and 10.54 seconds. The mechanism, how to count your own modules, and where the fix stops working.