I run ClockAura, a free site of online timers: countdowns, a Pomodoro timer, a stopwatch, interval timers for the gym. The first versions had the bug I suspect every browser timer has at some point: start a countdown, switch to another tab, and it rings late. Sometimes minutes late.
The bug wasn't in one line of code. It came from an assumption: that the browser keeps running your code at the same pace when nobody is looking. It doesn't. This post covers what I changed and why. The code snippets are lightly trimmed from the real hook that runs every countdown on the site.
The naive version
My first countdown looked like most tutorials:
// Don't do this
const id = setInterval(() => {
setRemaining((r) => r - 1000);
}, 1000);
Two separate things are wrong here.
First, setInterval(fn, 1000) doesn't promise a call every 1000 ms. It promises at least 1000 ms between calls. Every late call adds a little drift, and subtracting a fixed 1000 bakes that drift into the result. Over a 25-minute Pomodoro you can lose whole seconds even in a foreground tab.
Second, background tabs. Chrome limits timers in hidden tabs to roughly one wake-up per second, and after a page has been hidden for a few minutes it can batch chained timers much more aggressively, down to about once a minute. Firefox and Safari have their own versions of this. A countdown that counts ticks is at the mercy of how many ticks the browser lets you have.
Rule 1: store the end, not the remaining
The fix most people land on, and the one that matters most: when the timer starts, compute the moment it should end, and from then on derive everything from that moment.
useEffect(() => {
if (running) {
targetRef.current = performance.now() + remaining;
liveRef.current = true;
rafRef.current = requestAnimationFrame(tick);
armBackup();
}
// cleanup cancels the frame and the timeout
}, [running, tick]);
Each tick then asks "how far are we from the end?" instead of "subtract one more second":
const tick = useCallback(() => {
if (!liveRef.current) return;
const left = Math.max(0, targetRef.current - performance.now());
setRemaining(left);
if (left <= 0) { /* finish: see Rule 3 */ return; }
rafRef.current = requestAnimationFrame(tick);
}, []);
A late tick no longer causes drift. If a frame arrives 300 ms late, the display jumps 300 ms and is correct again. Nothing accumulates.
I use performance.now() here, not Date.now(). performance.now() is monotonic: it doesn't move when the user's clock syncs with a time server or when someone changes the system time. Date.now() can jump backwards or forwards, and a countdown that gains five minutes because Windows corrected the clock is a fun bug report to read.
There is one place where I do use Date.now(): when a running timer is saved to localStorage so it can be resumed after a reload. performance.now() restarts at zero on every page load, so it can't be stored. The saved record keeps a wall-clock endsAt, and the page turns it back into a duration when you come back:
const left = t.endsAt ? t.endsAt - Date.now() : t.left;
Rule 2: don't trust requestAnimationFrame to finish the job
I moved the display loop to requestAnimationFrame because it lines up with the screen refresh and makes the digits look smooth. The catch: browsers pause rAF entirely in hidden tabs. Not slow it down. Pause it.
With a rAF-only loop, the end-timestamp math is still correct, but nothing runs the check. So the timer "finishes" the moment you switch back to the tab, which might be ten minutes after it should have rung. That was most of the late-alarm reports.
The fix is a second, much simpler mechanism: one setTimeout aimed straight at the end time.
const armBackup = useCallback(() => {
clearTimeout(backupRef.current);
if (!liveRef.current) return;
const left = targetRef.current - performance.now();
backupRef.current = setTimeout(() => {
if (!liveRef.current) return;
if (targetRef.current - performance.now() <= 0) tick();
else armBackup();
}, Math.max(0, left) + 15);
}, [tick]);
A few details that took me more than one try:
- One long timeout, not a ticking one. Background throttling hits chained timers hardest: timers that schedule timers, again and again. A single timeout set for 25 minutes is about the friendliest thing you can ask a throttled tab for. At worst it gets lined up with the next one-second boundary, which is fine for a kitchen timer and a Pomodoro.
-
The +15 ms. Timer callbacks and
performance.now()don't share an exact clock, andperformance.now()is deliberately coarsened for security reasons. Without the padding, the timeout sometimes fired a hair before the target, saw 2 ms left, and had to re-arm. The padding makes the common case a single wake-up. - Re-arm if early. If it does fire early anyway, it schedules itself again for the real remainder instead of giving up or ringing early.
When the user adds a minute with the +1 button, the end moves, so the backup has to move too:
const addMs = useCallback((deltaMs) => {
setRemaining((prev) => {
const next = Math.max(0, prev + deltaMs);
targetRef.current += next - prev;
return next;
});
setTimeout(armBackup, 0); // end time moved: re-aim the backstop
}, [armBackup]);
The setTimeout(armBackup, 0) is there because the state updater runs later than you might expect. Re-arming on the next turn means the backup reads the updated target.
Rule 3: two ways to finish means two ways to ring twice
Once both rAF and setTimeout can end the timer, you have a race. Come back to the tab at exactly the wrong moment and both fire within a few milliseconds. The user hears the alarm twice and the "focus minutes" stat would count the session twice.
The guard is one ref that both paths check, and the finishing path flips it first, before doing anything else:
if (left <= 0) {
liveRef.current = false; // close the door first
clearTimeout(backupRef.current); // cancel the other path
cancelAnimationFrame(rafRef.current);
setRunning(false);
onDoneRef.current(); // ring exactly once
return;
}
It has to be a ref, not state. State updates are batched and applied on the next render, so two callbacks in the same turn would both read the old running === true. A ref is just a mutable box, and the second caller sees false immediately.
onDone lives in a ref for a related reason: the page passes a fresh callback on every render, and I didn't want tick to be recreated 60 times a second because of it.
Rule 4: keep the screen on, but only when it matters
A lot of timers end up on a projector or a TV: classroom timers, workout timers across the room. Nothing kills that faster than the laptop dimming its screen at minute nine.
The Screen Wake Lock API fixes it in a few lines, with two catches. The lock is released automatically whenever the page is hidden, and it's not re-acquired when you come back. So the hook listens for visibilitychange and asks again:
const onVisible = () => {
if (active && document.visibilityState === 'visible' && !lockRef.current) acquire();
};
document.addEventListener('visibilitychange', onVisible);
The other catch is about scope. The lock is held while a timer runs or while the page is full screen, and released otherwise. A wake lock that stays on after the timer ends drains a laptop battery for nothing.
Rule 5: the service worker must never serve an old timer
Timers are the kind of page people open with bad Wi-Fi: in a gym basement, a classroom, a kitchen. So the site has a service worker that caches the core timer pages after the first visit, and they open offline.
The thing I was most nervous about was the opposite problem: shipping a fix and having half my users stuck on the cached old page. The rules I settled on:
- HTML is network-first. The cached copy is only used if the network fails, or on a repeat visit when the network hasn't answered within 700 ms.
-
Hashed assets are cache-first.
/assets/index-a1b2c3d4.jsnever changes content, so caching it forever is safe. -
The cache name includes the build id. Every deploy creates a new cache and the
activatehandler deletes the others, so a cached page always matches the scripts cached with it.
const VERSION = '__BUILD_ID__'; // replaced at build time
const CACHE = `clockaura-${VERSION}`;
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)));
await self.clients.claim();
})());
});
The service worker has nothing to do with timing accuracy, but it's part of the same promise: when you press Start, the timer works, whatever the tab or the network is doing.
One gap I found while writing this
The tab title ("04:59 ยท Countdown") and the little progress ring in the favicon were updated from the same render loop as the digits, and that loop is driven by rAF. In a background tab the ending was reliable, thanks to the backup timeout, but the title could sit still until you looked at the tab again. Ironically, a background tab is exactly when you'd look at the title. The fix is a one-second interval that only runs while document.hidden is true and only updates the title. Background throttling allows one wake-up per second, and that's all a title needs.
The short version
If you're building anything that counts down in a browser:
- Store the end time and derive the remaining time from it. Never subtract fixed ticks.
- Use
performance.now()inside a page andDate.now()only for anything you save. - Drive the display with rAF, but back up the finish with one
setTimeoutaimed at the end. - Guard the finish with a ref so it can only happen once.
- Re-acquire the wake lock on
visibilitychange.
If you want to see it all working, start a Pomodoro timer, switch to another tab, and wait for the bell. Or open a link timer like clockaura.com/t/25m, which starts a 25-minute countdown straight from the URL.
I'm happy to answer questions in the comments, especially if you've found a browser where this still rings late.
Top comments (0)