OcxlyDev · Tutorial

Build a drift-free countdown timer & stopwatch

Almost every timer tutorial online is subtly wrong: it counts down by subtracting one from a variable every second. That drifts, and in a background tab it falls apart. Here is how to build one that stays accurate to the millisecond — the approach behind our Countdown Timer & Stopwatch.

Why the obvious way drifts

The naive timer decrements a counter inside setInterval(fn, 1000). The problem: setInterval does not fire exactly every 1000 ms. It guarantees at least the delay, then runs whenever the event loop is free — and nested or background timers are throttled to save power.12 Each late tick adds error, and the errors accumulate. Over ten minutes a naive timer can be seconds off.

javascript
// ✗ drifts — every late tick is permanent error
let left = 300;
setInterval(() => { left -= 1; render(left); }, 1000);

The fix: anchor to a clock, don't accumulate

Instead of counting ticks, record when the timer should end and, on every frame, ask how much real time is left. Use performance.now() — a monotonic, sub-millisecond clock that, unlike Date.now(), never jumps when the system clock changes.3

javascript
let endAt = performance.now() + remainingMs;

function tick() {
  const left = endAt - performance.now();   // re-derive from the clock
  if (left <= 0) { render(0); finish(); return; }
  render(left);
  requestAnimationFrame(tick);
}

Now a late frame is harmless: the very next frame recomputes from the true end time and self-corrects. Pausing just stores remainingMs = endAt − performance.now(), and resuming sets a fresh endAt.

Render on animation frames, not intervals

Drive the display with requestAnimationFrame rather than a 1000 ms interval.4 It repaints in sync with the screen (smooth for the stopwatch's hundredths), automatically pauses in hidden tabs, and never fights the event loop. Because the displayed value is derived from the clock, the ragged frame timing is invisible.

Surviving the background tab

When a tab is hidden, browsers throttle timers and animation frames hard.2 The clock-anchored design already handles this — whenever the tab wakes, the next frame shows the correct remaining time, no matter how long it slept. If you want the countdown to finish on time even while hidden, note the wall-clock deadline and reconcile on the visibilitychange event.5

A chime with zero assets

You do not need an audio file. The Web Audio API can synthesise a beep from an oscillator.6 One important rule: browsers block audio until the user interacts with the page, so create or resume() the AudioContext from the click that starts the timer.7

javascript
function beep(ac) {
  const osc = ac.createOscillator();
  const gain = ac.createGain();
  osc.frequency.value = 880;              // A5
  osc.connect(gain); gain.connect(ac.destination);
  const t = ac.currentTime;
  gain.gain.setValueAtTime(0.0001, t);
  gain.gain.exponentialRampToValueAtTime(0.4, t + 0.02);
  gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
  osc.start(t); osc.stop(t + 0.24);
}

An optional desktop notification

To alert a user who has tabbed away, ask for permission (once, from a user gesture) and post a Notification when the timer ends.8 Always treat it as optional — permission may be denied, and the on-page chime and flash should carry the message on their own.

The stopwatch is the same idea, inverted

A stopwatch measures elapsed time instead of remaining time: store startTs = performance.now(), and each frame show elapsed + (performance.now() − startTs). A lap is just a snapshot of that value, and the split is the difference from the previous lap. Same monotonic clock, same animation-frame loop — accurate to the hundredth, drift and all left behind.

References

  1. MDN Web Docs — setInterval: reasons for delays longer than specified.
  2. MDN Web Docs — timer throttling in inactive/background tabs.
  3. MDN Web Docs — performance.now(): a monotonic, high-resolution timestamp.
  4. MDN Web Docs — requestAnimationFrame().
  5. MDN Web Docs — the Page Visibility API (visibilitychange).
  6. MDN Web Docs — OscillatorNode: synthesising tones with Web Audio.
  7. MDN Web Docs — autoplay policy: resume an AudioContext from a user gesture.
  8. MDN Web Docs — Notification.requestPermission().