A countdown that stays accurate in a background tab
Add to Google
Creem.io
Merchant of record payments for SaaS and digital products, handling checkout, subscriptions, taxes, invoices, and global payment operations.
You add a ten minute hold on the checkout: setInterval, decrement a counter, render. It tests perfectly. Then a real user opens their email in another tab to find the discount code, comes back ninety seconds later, and the timer says nine minutes fifty. Meanwhile the server released their reservation.
The bug is not your math. Browsers throttle timers in background tabs down to roughly once a minute, and they stop them entirely in tabs that get frozen. Anything counting ticks is counting how often the browser felt like waking you up.
The fix
Stop counting. Store the moment it ends and subtract:
const target = Date.now() + 10 * 60 * 1000;
let timer;
function tick() {
const remaining = Math.max(0, target - Date.now());
render(remaining);
clearTimeout(timer);
if (remaining === 0) return expired();
timer = setTimeout(tick, remaining % 1000 || 1000);
}
tick();
Now it does not matter whether the tick ran once or not at all. Every render asks the clock what time it is.
Naive: subtract the interval
Deadline: target - now
Hidden for 0s. The naive timer is 0s behind reality.
The remaining % 1000 line
A plain setTimeout(tick, 1000) drifts, because each round trip costs a few milliseconds and they accumulate. Worse, it drifts out of phase, so the display sits on the same number for 1.4 seconds and then skips one.
remaining % 1000 is the time until the displayed number actually changes. The timer lands on the boundary every time, so the seconds flip cleanly and stay in step no matter how long the tab was asleep.
Come back to a correct number instantly
A throttled tab may not fire for another minute after the user returns, so force one render on the way back:
addEventListener('visibilitychange', () => {
if (!document.hidden) tick();
});
tick() clears its own pending timer before scheduling the next one, so calling it directly never leaves two chains running. That is the only reason clearTimeout is in there.
Whose clock is it
Date.now() is the user’s device clock, and device clocks are wrong. Some are minutes off, some are in a different year, and some belong to a person who noticed your flash sale timer and set the clock back.
For anything where the deadline has consequences, have the server send an absolute expiry and correct for the difference:
const response = await fetch('/api/checkout/hold');
const { expiresAt } = await response.json();
const offset = Date.parse(response.headers.get('Date')) - Date.now();
const target = Date.parse(expiresAt) - offset;
Every HTTP response carries a Date header, so the correction is free. And the rule underneath it: the countdown is a display. The server decides whether the hold, the session, or the discount is still valid, and it does that by checking its own clock when the next request arrives. A client-side timer is never the authority.
Formatting it
function render(ms) {
const total = Math.floor(ms / 1000);
const minutes = String(Math.floor(total / 60)).padStart(2, '0');
const seconds = String(total % 60).padStart(2, '0');
output.textContent = `${minutes}:${seconds}`;
}
.countdown { font-variant-numeric: tabular-nums; }
tabular-nums makes every digit the same width, which stops the timer from jittering sideways as the numbers change. It is one line and it is the difference between a countdown that looks built and one that looks like a bug.
Do not announce every second
Putting aria-live="polite" on a per-second counter means a screen reader reads a number out loud sixty times a minute, over everything else on the page. It makes the checkout unusable.
Leave the element silent, and announce the moments that matter in a separate live region: “Five minutes left”, then “One minute left”. That is the information a sighted user is getting from a glance, at the rate they are getting it.
Where this comes up
- Checkout holds: tickets, seats, limited stock reservations.
- Session expiry warnings: “You will be signed out in 2:00”, with a button to stay.
- One-time codes: the countdown before Resend becomes clickable.
- Auctions and drops: where a wrong number is a support ticket at best.
Why this matters
- The failure only happens to real users: you never leave the tab while testing, and every user does.
- It is fewer lines, not more: no accumulated counter to keep in sync, no correction logic, no pause and resume handling.
- A drifting timer is worse than no timer: the user trusted your number, acted on it, and lost the thing you promised was still theirs.
Search your code for setInterval next to a variable that gets decremented. That is the pattern, and this is a ten minute rewrite.
Happy coding!
Marko