DEV Community

HAU
HAU

Posted on

JavaScript Countdown Timers: Why setInterval Drifts and How to Fix It

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Scheduling jitter — The browser/Node event loop fires the callback a few milliseconds late due to other tasks
  2. Callback execution time — If your callback takes 5ms, the next interval starts 1005ms after the previous one began
  3. 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 ...
Enter fullscreen mode Exit fullscreen mode

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!')
);
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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();
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)