Why Your Countdown Falls Behind in a Background Tab: setInterval, Chrome’s Timer Throttling and a Timer That Keeps Time
I hid 25-minute countdowns in background tabs of Chrome 156. After eight minutes the one that subtracts a second per tick was 30 seconds behind and a chained setTimeout 54 seconds behind. The one that reads the clock was right. A 90-second alarm fired 0.8 s late with one setTimeout and 39 s late with a polling loop.
Almost every countdown on the web starts like this:
let remaining = 25 * 60;
setInterval(() => {
remaining--;
render(remaining);
}, 1000);It looks right on screen. Then someone starts a 25-minute focus session, switches to their editor, comes back, and the timer still shows several minutes to go after the real 25 minutes have passed. The code assumes the callback runs once per second. The browser never promised that, and in a background tab it often doesn’t.
I wanted numbers instead of a vague “background tabs are throttled”, so I measured it. Everything below was run on 27 October 2026 in Google Chrome 156.0.8078.47 on Linux. It was a real browser window with default settings, not headless. Each test page was hidden by opening a new tab in front of it. A small script read the page’s variables once a minute over the DevTools protocol.
The test page: five countdowns started at the same moment
const DURATION = 25 * 60;
const start = Date.now();
const end = start + DURATION * 1000;// 1. naive: subtract one second per tick
let naive = DURATION;
setInterval(() => { naive--; }, 1000);// 2. deadline: recompute from the clock on every tick
let deadline = DURATION;
setInterval(() => {
deadline = Math.max(0, Math.round((end - Date.now()) / 1000));
}, 1000);// 3. chained setTimeout, naive
let chained = DURATION;
(function tick() {
setTimeout(() => { chained--; tick(); }, 1000);
})();// 4. a requestAnimationFrame counter: does it run at all while hidden?
let frames = 0;
(function frame() { frames++; requestAnimationFrame(frame); })();
A second version of the page also drove a naive countdown from a dedicated Web Worker. A third left out a line that wrote the time into document.title every second. That line turned out to matter a lot.
Result 1: eight minutes in a background tab
These are the readings from the page without a worker, hidden 6 seconds after it loaded:
hidden for real time left naive chained setTimeout deadline
1 min 23:53 23:54 23:57 23:54
3 min 21:53 21:54 22:04 21:54
8 min 16:53 17:23 17:47 16:54After eight minutes the naive countdown was 30 seconds behind and the chained setTimeout was 54 seconds behind. The deadline version was right; its one-second difference comes from rounding and the moment of its last tick. When I brought the tab back to the front, the two tick-counting versions stayed wrong. Nothing in their code knows how much time has really passed.
requestAnimationFrame stopped completely. The frame counter did not move once while the tab was hidden. Anything that updates a countdown from an animation loop simply freezes in the background.
On a different run, a page that also ran a worker was hidden for ten minutes. There the naive countdown ended 45 seconds behind and the chained one 80 seconds behind. The exact amount changes from run to run. The direction never does.
Why the chained setTimeout drifts from the first minute
The log of callback times explains the two kinds of drift. In the first five minutes the chained setTimeout had 17 gaps of 1,997 ms instead of 1,000 ms. The setInterval had none.
Chrome’s article on timer throttling in Chrome 88 describes the first stage. When a page is hidden, timers are put in a group that “the browser will check … once per second”. Timers with similar timeouts get batched together. A setInterval stays in step with those checks. A chained setTimeout asks for 1,000 ms from the moment its callback ran, which is slightly after a check. Now and then it just misses one and waits for the next, so a second goes missing. Those missed seconds make up most of the 11-second lag at the three-minute reading in the table.
Why both fall behind after five minutes
The setInterval log shows a single 30,000 ms gap at the five-minute mark. That is the second stage, which the same Chrome article calls intensive throttling. It applies when all of these are true:
- the page has been hidden for more than 5 minutes,
- the chain count is 5 or greater,
- the page has been silent for at least 30 seconds,
- WebRTC is not in use.
Then “the browser will check timers in this group once per minute”. The article also defines the chain: with setInterval, "each iteration is part of the chain". A once-a-second interval reaches a chain count of 5 after five seconds and stays there for good.
So the naive countdown lost 30 seconds in one go. But why did it then go back to ticking once per second, instead of losing most of every minute?
Result 2: the line that wrote to document.title
The answer is in Chromium’s page scheduler. When a page updates its title or favicon, intensive throttling is inhibited for same-origin frames for 3 seconds. The comment says this “enables alternating effects meant to grab the user’s attention”. My page updated the title every second, so each update kept the next one-second tick out of intensive throttling. Only the first tick after the five-minute mark had to wait for a once-a-minute check.
To test this, I ran the page with and without the title line side by side. I used the flag from the Chrome article, --enable-features=IntensiveWakeUpThrottling:grace_period_seconds/10, which starts intensive throttling 10 seconds after the page is hidden instead of 5 minutes. After about four minutes (247 s):
interval ticks naive shows real time left
page that updates document.title 211 21:29 20:53
page that never touches it 62 23:58 20:53Without the title update, the page got 62 interval callbacks in 247 seconds. The naive countdown was more than three minutes behind. That version’s deadline countdown read the clock correctly every time it ran. But it ran only once a minute, so its stored value lagged by up to a minute. On the next tick after the tab came back it was correct again.
So whether a background countdown is 30 seconds off or three minutes off depends on whether the page happens to write its title. That makes a poor foundation for anything you rely on.
Result 3: a long task does the same thing in a visible tab
Throttling is not the only way to lose ticks. In a visible tab, I blocked the main thread for 5 seconds with a busy loop, the kind of long task that heavy rendering or a large JSON.parse can cause. The interval gaps were:
setInterval gaps (ms): 1000, 1000, 5224, 775
worker message gaps (ms): 1000, 1000, 5214, 0, 0, 0, 0, 786setInterval does not make up for callbacks it missed. It ran once after the block and carried on, and the naive countdown was 4 seconds behind (24:55 against a real 24:51). The worker kept ticking during the block. Its messages queued up and arrived together when the main thread was free, so the worker-driven count stayed correct.
Get Olgun Özoktaş’s stories in your inbox
Join Medium for free to get updates from this writer.
In my hidden-tab runs the worker’s one-second interval was not throttled either: one message a second for ten minutes, with the longest gap 1,008 ms. That is an observation from one browser, not a documented guarantee. A worker doesn’t help if what you need is the screen to repaint or a sound to play.
Result 4: the alarm at the end
A countdown has two jobs: show the time left, and do something when it reaches zero. I tested the second job on its own. The tab was hidden, intensive throttling started after 10 seconds (the same flag), and I set a 90-second alarm three ways:
const due = Date.now() + 90000;// A. one long setTimeout (chain count 1)
setTimeout(fire, 90000);// B. a 1-second polling loop with chained setTimeout
(function poll() {
setTimeout(() => Date.now() >= due ? fire() : poll(), 1000);
})();// C. setInterval polling every second
const id = setInterval(() => {
if (Date.now() >= due) { clearInterval(id); fire(); }
}, 1000);
A. single setTimeout 790 ms late
B. chained polling 38,791 ms late
C. setInterval polling 38,791 ms lateThe single setTimeout has a chain count of 1. It never qualifies for intensive throttling, only the once-per-second check. The polling loops reach a chain count of 5 within seconds and get the once-a-minute check. Both fired at the same minute-aligned moment, almost 39 seconds after the deadline.
A countdown that keeps time
Put together, the fix is short:
function startCountdown(seconds, { render, done }) {
const end = Date.now() + seconds * 1000;
const left = () => Math.max(0, Math.ceil((end - Date.now()) / 1000));// Display: recompute from the clock, never count ticks
const id = setInterval(() => render(left()), 1000); // Repaint at once when the user comes back
const onVisible = () => {
if (document.visibilityState === 'visible') render(left());
};
document.addEventListener('visibilitychange', onVisible); // The end: one timer, not a polling loop
const alarm = setTimeout(() => {
clearInterval(id);
document.removeEventListener('visibilitychange', onVisible);
render(0);
done();
}, seconds * 1000); render(left());
return () => {
clearInterval(id);
clearTimeout(alarm);
document.removeEventListener('visibilitychange', onVisible);
};
}
What each part is for:
- The display reads the clock. The interval is only a request to repaint. If ticks are dropped, batched or delayed, the next one still shows the right number. Pausing works the same way: save the time left when the user pauses, and set a new
endwhen they resume. visibilitychangerepaints at once. With a throttled interval, a hidden tab can show a stale value for up to a minute. The event fixes the display as soon as the user looks.- The end is a single
setTimeout. In my test it fired within a second. A polling loop fired 39 seconds late. - Don’t count frames.
requestAnimationFrameis fine for smooth animation while visible, but it stops while hidden.
Date.now() follows the system clock, so it jumps if the user or NTP changes the clock in the middle of a countdown. For a focus timer that is rarely a problem, and it is the clock the user compares against anyway.
Other browsers
Chrome is not the strictest. MDN’s setTimeout page says that “Firefox Desktop has a minimum timeout of 1 second for inactive tabs” and that “Firefox for Android has a minimum timeout of 15 minutes for inactive tabs and may unload them entirely.” An unloaded tab runs no code at all. If a timer has to survive that, the end time needs to live somewhere that outlasts the page, such as localStorage, so a reloaded tab can work out where it should be. Anything that must alert the user at an exact moment on a phone is a job for a native notification, not a browser tab.
Testing your own timer in two minutes
You don’t need to wait five minutes per experiment:
- Start Chrome with
--enable-features=IntensiveWakeUpThrottling:grace_period_seconds/10. - Start your countdown next to a stopwatch that reads the clock. The FindUtils Stopwatch does. In my run its display only updated once a minute while hidden, but it showed the correct elapsed time as soon as the tab was back in front.
- Switch to another tab for two or three minutes, then come back and compare the two.
- Repeat with a page that doesn’t change its title. That is the version that shows the real worst case.
If you store end times as timestamps, the Unix Timestamp Converter turns a Date.now() value like 1793079000000 into a readable date and back. The post on seconds versus milliseconds covers what goes wrong when a millisecond value like this one meets code that expects seconds.
Summary
setInterval(fn, 1000)means "about once a second while the page is visible and idle". Anything else is a bonus.- Hidden for eight minutes, counting ticks put a countdown 30 seconds behind. A chained
setTimeoutwas 54 seconds behind. Without a title update, the gap after four minutes was more than three minutes. - Compute the time left from a deadline, repaint on
visibilitychange, and use onesetTimeoutfor the end. - Test with the grace-period flag, and test the version of your page that doesn’t touch
document.title.
Need help with a project? The team behind FindUtils takes on freelance work. You can find us on Upwork. We also build React Native apps for iOS and Android connected to your web app, where the same JavaScript timer code has to survive the app going to the background.









