Building a countdown timer feels like a two-minute task:
let seconds = 60;
const timer = setInterval(() => {
seconds--;
display(seconds);
if (seconds <= 0) clearInterval(timer);
}, 1000);
Ship it. Done. Except… after a few minutes, your “60 second” timer has taken 63 seconds of wall-clock time. Users notice. This is timer drift, and every interval-based timer has it.
Why setInterval Drifts
setInterval(fn, 1000) doesn’t fire exactly every 1000ms. It fires “at least 1000ms after the last call, when the event loop is free.” Three sources of error compound over time:
- Scheduling jitter — The browser/Node event loop fires the callback a few milliseconds late due to other tasks
- Callback execution time — If your callback takes 5ms, the next interval starts 1005ms after the previous one began
- Tab throttling — Browsers throttle timers in background tabs to 1 second minimum (sometimes more)
Small errors, big compounding:
Expected: 0ms 1000ms 2000ms 3000ms 4000ms ...
Actual: 0ms 1004ms 2009ms 3013ms 4018ms ...
After 5 minutes, you’re 5.4 seconds behind. After an hour? 65+ seconds of drift.
The Fix: Anchor to Wall-Clock Time
Instead of counting ticks, measure elapsed time against a fixed reference point:
function createCountdown(durationMs, onTick, onComplete) {
const endTime = Date.now() + durationMs;
function tick() {
const remaining = endTime - Date.now();
if (remaining <= 0) {
onTick(0);
onComplete();
return;
}
onTick(remaining);
// Schedule next tick relative to how much time SHOULD have passed
const nextTick = remaining % 1000 || 1000;
setTimeout(tick, nextTick);
}
tick();
}
// Usage
createCountdown(
60_000,
(ms) => display(Math.ceil(ms / 1000)),
() => console.log('Done!')
);
This approach:
- Never accumulates drift, because every tick calculates remaining time fresh
- Self-corrects automatically after tab throttling
- Fires extra-soon when recovering from a delayed tick
Countdown to a Future Date
For “days until” counters (time until an event, product launch, etc.), use the same principle:
function countdownTo(targetDate) {
function update() {
const now = Date.now();
const target = new Date(targetDate).getTime();
const diff = target - now;
if (diff <= 0) {
render({ days: 0, hours: 0, minutes: 0, seconds: 0 });
return;
}
render({
days: Math.floor(diff / 86_400_000),
hours: Math.floor((diff % 86_400_000) / 3_600_000),
minutes: Math.floor((diff % 3_600_000) / 60_000),
seconds: Math.floor((diff % 60_000) / 1000),
});
// Sync to the next whole second
setTimeout(update, diff % 1000 || 1000);
}
update();
}
countdownTo('2026-12-25T00:00:00');
The key line is diff % 1000 || 1000 — this syncs the next update to fire right when the second digit changes, rather than on a fixed interval.
Handling Visibility Changes
Browsers aggressively throttle background tabs. When the user switches back, your timer might jump 30+ seconds at once. Handle it:
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
// Force immediate re-render when tab becomes visible
update();
}
});
Since update() always reads Date.now(), it will immediately show the correct remaining time — no accumulated error to correct.
Web Workers: When You Need Precision in Background Tabs
If your countdown genuinely must be accurate in background tabs (e.g., a timed exam), browsers won’t throttle Web Worker timers the same way:
// worker.js
self.onmessage = ({ data: { endTime } }) => {
const tick = () => {
const remaining = endTime - Date.now();
self.postMessage({ remaining });
if (remaining > 0) setTimeout(tick, remaining % 1000 || 1000);
};
tick();
};
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ endTime: Date.now() + 300_000 });
worker.onmessage = ({ data: { remaining } }) => render(remaining);
Quick Reference
| Approach | Drift | Background accuracy | Complexity |
|---|---|---|---|
setInterval counting |
Accumulates | Poor | Low |
setTimeout + Date.now()
|
None | Moderate | Medium |
| Web Worker | None | Good | Higher |
requestAnimationFrame |
None | Poor (pauses when hidden) | Medium |
For countdowns to specific future dates, tools like datetimecalculator.app/days-until give you the static number without any drift concerns — useful for planning how long you have before you need to implement the real thing.
What’s the worst timer drift bug you’ve shipped? Mine was a “60-second” quiz timer that gave users 68 seconds on slow laptops.
