iKit
Technical · 10 min read ·

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.

Background Tab Throttling: Why Your JS Timer Pauses (2026)

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.visibilityState at 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

Related on iKit

Related posts