Background Tab Throttling: Why Your JS Timer Pauses (2026)
Background tab throttling is why your JavaScript timer falls minutes behind. Here is what Chrome and Firefox actually do, and the fix that survives it.
Why Background Tabs Pause Your JavaScript Timer
Start a 25-minute countdown, switch to another tab, come back, and it shows 19 minutes remaining instead of 3. Nothing crashed. Your page was hidden, so the browser applied background tab throttling and stopped waking your timer on schedule. This is a deliberate battery-saving intervention, it behaves differently at 30 seconds, 5 minutes, and with audio playing — and there is a fix that survives all of it.
TL;DR
- Hidden pages get their timers checked once per second, then once per minute after 5 minutes.
- Chrome calls the deep stage "intensive throttling"; it shipped in Chrome 88, January 2021.
- Tabs with audio, WebSockets, or WebRTC are exempt from the aggressive stages.
- Never count ticks. Store an absolute deadline and recompute elapsed time on every tick.
- Web Workers are less exposed, but a deadline-based timer fixes the problem without them.
Why does my JavaScript timer stop in a background tab?
It never actually stops. setInterval(tick, 1000) keeps its registration in the timer map; the browser simply checks that map far less often once your page is out of sight. From your callback's point of view, one tick arrives, then the next arrives 60 seconds later.
What "hidden" means to the browser
Hidden usually means another tab is active, the window is minimised, or the OS screen has switched off. It is not the same as unfocused: per MDN's Page Visibility documentation, clicking into another application blurs your window but does not necessarily hide it. An <iframe> inherits its parent's visibility state, and hiding one with display: none does not fire a visibility change at all.
The authoritative signal is document.visibilityState, which is either "visible" or "hidden":
document.addEventListener("visibilitychange", () => {
console.log(document.visibilityState, Date.now());
});
The three throttling stages in Chrome
Chrome's model has three tiers, described by Jake Archibald in the Chrome 88 announcement of heavy timer throttling. Which tier you land in depends on visibility, on whether the page has made a sound in the last 30 seconds, and on the chain count — how many times a timer has rescheduled itself. Every iteration of setInterval adds one to that chain, and so does a setTimeout that calls setTimeout from inside its own callback.
| Stage | Applies when | Callbacks checked |
|---|---|---|
| Minimal | Page visible, or made sound in last 30s | On schedule, 4 ms floor |
| Throttled | Hidden under 5 min, or chain under 5, or WebRTC live | Once per second |
| Intensive | Hidden over 5 min, chain 5+, silent 30s+, no WebRTC | Once per minute |
Two details in that table catch people out. First, the intensive stage is evaluated when a timer is scheduled, not when it fires — a timer registered before the tab went quiet can behave differently from one registered after. Second, because checks are batched, several timers with similar delays collapse into a single wake-up, which is the whole point: fewer wake-ups, less CPU, longer battery life.
Which tabs are exempt from throttling
Some categories are deliberately spared. A tab producing audible sound is treated as foreground — and a silent audio track does not qualify, because that trick was anticipated. Tabs holding real-time connections are also exempt: MDN notes that WebSocket and WebRTC traffic runs unthrottled specifically so connections are not dropped by timeouts, and IndexedDB work gets the same treatment.
How browsers throttle setTimeout and setInterval
Background throttling is the dramatic case, but clamping happens even on a visible page.
The 4 ms clamp after five nested timers
The HTML Standard's timers section is explicit: if the nesting level is greater than 5 and the requested timeout is less than 4, the timeout is set to 4. So setTimeout(fn, 0) in a chain does not mean zero.
const start = performance.now();
let n = 0;
(function loop() {
if (++n > 10) {
console.log((performance.now() - start) / 10);
return; // ~4 ms per hop, not ~0
}
setTimeout(loop, 0);
})();
Nolan Lawson measured this across engines in 2025 and found a median setTimeout hop of roughly 4.2 ms in Chrome 139 and 4.7 ms in Firefox 142, with Safari 18.4 clamping far harder at about 26.7 ms. The same benchmark put scheduler.postTask and MessageChannel.postMessage near zero, which is why scheduling libraries reach for those instead.
Budget-based throttling in Firefox and Chrome
Beyond the clamp, both engines run a budget system for background windows. MDN describes it as a per-window time budget that regenerates at 10 ms per second; a timer task is only allowed to run while the budget is non-negative, and the execution time of each callback is subtracted from it. Firefox starts applying this after roughly 30 seconds in the background, Chrome after about 10. The practical effect: a background tab that does heavy work per tick throttles itself harder than one that does almost nothing.
What requestAnimationFrame does instead
requestAnimationFrame is not throttled — it is suspended outright. Browsers stop delivering animation frames to hidden pages, so an rAF-driven clock does not tick slowly, it stops dead and resumes on return. That is the right behaviour for animation and the wrong behaviour for elapsed-time tracking, which is why a countdown should never be driven by rAF alone.
How to fix a timer that pauses in a background tab
The fix is not to defeat throttling. It is to write a timer whose correctness does not depend on how often it runs.
Store a deadline, not a tick count
Any timer that does remaining -= 1 per tick is a tick counter, and tick counters lose exactly as much time as the browser withholds. Store the deadline once and derive the display:
function startCountdown(seconds, onTick, onDone) {
const deadline = performance.now() + seconds * 1000;
function frame() {
const left = Math.max(0, deadline - performance.now());
onTick(Math.ceil(left / 1000));
if (left === 0) return onDone();
// land on the next whole second, not +1000
setTimeout(frame, left % 1000 || 1000);
}
frame();
}
Two things make this throttle-proof. The remaining time comes from a clock reading, so a 4-minute gap between ticks costs you nothing but a stale display during the gap. And each delay is computed to land on the next second boundary rather than blindly adding 1000 ms, so the display never slides out of phase. If you also need the countdown to survive a page reload, store an epoch deadline instead of a monotonic one — our Unix timestamp converter is handy for sanity-checking those values, and the millisecond-versus-second trap is a real one.
Repaint immediately on visibilitychange
The user's first impression on returning to the tab is whatever was painted before it was hidden. Force a redraw the moment the page becomes visible:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
render(); // recompute from the deadline, then paint
}
});
Pair this with a check for whether the deadline passed while hidden. If it did, fire the completion path once — not once per missed tick. Counting missed intervals and replaying them is the classic bug that turns a returning tab into a burst of 300 notifications.
Ring on time with a scheduled alarm
Throttling delays your callback, so a browser alarm implemented purely as "play a sound when the tick reaches zero" can fire a minute late. If exact ring time matters, schedule the audio against the audio clock rather than the timer callback, or request permission and use a notification, and treat the visual countdown as a display concern only. This is why iKit's Stopwatch & Timer and Countdown Timer both recompute from a stored deadline: the number you see on return is correct even if the tab sat hidden for an hour.
Do Web Workers avoid background tab throttling?
Mostly, and for an interesting reason that is visible in the spec text rather than in any vendor blog post.
What the HTML spec actually says about workers
The HTML Standard defines the wait differently for the two global types. For a Window, the algorithm waits until the associated document has been fully active for a further N milliseconds — time spent inactive does not count. For a WorkerGlobalScope, it waits until N milliseconds have passed with the worker not suspended. Different gate, different exposure to page visibility.
// worker.js
let deadline = 0;
onmessage = (e) => { deadline = e.data.deadline; };
setInterval(() => {
postMessage({ left: Math.max(0, deadline - Date.now()) });
}, 1000);
When a worker is worth it
A worker earns its keep when the tick does real work — parsing, hashing, decoding — that you want off the main thread anyway. It is not worth it purely to keep a number moving, because the message still has to cross to the main thread to be painted, and paints do not happen in a hidden tab regardless.
What workers do not fix
A worker does not give you a guarantee. Throttling policy is an implementation decision, browsers have tightened it repeatedly since 2017, and nothing prevents a future engine from budgeting worker timers too. A deadline-based timer is correct under every policy; a worker merely makes the current policy less painful. Use both if you like, but rely on the first.
How to test background tab throttling in Chrome
Waiting five minutes per test run is not a workflow. Chrome exposes a flag that shortens the grace period so intensive throttling kicks in after 10 seconds:
chrome --enable-features="IntensiveWakeUpThrottling\
:grace_period_seconds/10"
Launch a Canary or Beta build with that flag, open your timer, switch tabs, wait 15 seconds, and log the interval between consecutive ticks. You should see gaps jump from about 1000 ms to about 60000 ms. A correct timer shows the right value on return regardless of the gap size; a tick counter shows exactly how much time it lost.
Worth logging alongside it:
- Wall-clock gap between ticks, to confirm the stage you are in.
document.visibilityStateat each tick, to correlate.- Whether your completion path fired once or many times.
- Behaviour with an audio element playing, which should suppress the deep stage.
References
- Heavy throttling of chained JS timers beginning in Chrome 88 — Chrome's three throttling stages, chain-count rules, and the test flag.
- HTML Standard — 8.7 Timers — nesting-level clamp and the differing wait conditions for windows versus workers.
- Page Visibility API — visibility semantics, budget-based throttling figures, and the exemption list.
- Why do browsers throttle JavaScript timers? — 2025 cross-browser clamp measurements and the history behind the intervention.
Related on iKit
- setInterval drift and how to build a drift-free timer — the foreground half of this problem: why ticks slip even when the tab is visible.
- How to run an accurate countdown in the browser — what deadline-based timing looks like from the user's side.
- Online stopwatch with lap times, explained — lap timing across a tab switch is the fastest way to expose a tick-counting bug.
- Preset timer URLs for 5, 10 and 25 minutes — putting the duration in the link so a reload restores the same deadline.
- Why the Pomodoro 25/5 split still works — a 25-minute cycle spends most of its life in a background tab, which is exactly when throttling bites.
Related posts
CSV Type Inference: When "42" Should Stay a String (2026)
CSV files carry no types, so every parser guesses. Here is when a numeric-looking field should stay a string, and how to stop silent data loss.
Semicolon CSV Explained: Why European Excel Uses ; (2026)
Semicolon CSV files aren't broken exports. They come from Excel's locale list separator. Here's why it happens and how to read the files anywhere.
Notification API: Reminders That Survive a Tab Switch (2026)
The Notification API lets a timer reach you after you switch tabs. Here is the permission flow, the options that matter, and the failures nobody warns you of.