<!-- Markdown version of https://ocxly.com/build-countdown-timer.html · auto-generated, may lag the live page -->

# 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](countdown-timer.html).

## 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.[1](#ref-1)[2](#ref-2) Each late tick adds error, and the errors accumulate. Over ten minutes a naive timer can be seconds off.

```
// ✗ 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](#ref-3)

```
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](#ref-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](#ref-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](#ref-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](#ref-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](#ref-7)

```
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](#ref-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.](https://developer.mozilla.org/en-US/docs/Web/API/Window/setInterval#reasons_for_delays_longer_than_specified)
2. [MDN Web Docs — timer throttling in inactive/background tabs.](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#timeouts_in_inactive_tabs)
3. [MDN Web Docs — performance.now(): a monotonic, high-resolution timestamp.](https://developer.mozilla.org/en-US/docs/Web/API/Performance/now)
4. [MDN Web Docs — requestAnimationFrame().](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame)
5. [MDN Web Docs — the Page Visibility API (visibilitychange).](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API)
6. [MDN Web Docs — OscillatorNode: synthesising tones with Web Audio.](https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode)
7. [MDN Web Docs — autoplay policy: resume an AudioContext from a user gesture.](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_availability)
8. [MDN Web Docs — Notification.requestPermission().](https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission_static)

---
*Source: [ocxly.com/build-countdown-timer.html](https://ocxly.com/build-countdown-timer.html) — OCXLY, free 100% client-side privacy-first tools. Learn to build a drift-free countdown timer and stopwatch in JavaScript: why setInterval drifts, anchoring to performance.now(), requestAnimationFrame rendering, background-tab throttling, and a Web Audio chime. Cited to MDN.*
