DEV Community

Cover image for Why JavaScript Timers Drift: Building a High-Precision Metronome with Web Audio API
kandz
kandz

Posted on

Why JavaScript Timers Drift: Building a High-Precision Metronome with Web Audio API

If you have ever tried to build a timer, a clock, or a musical sequencer in JavaScript, you have probably run into a frustrating reality: JavaScript timers drift.

Whether you use setInterval, setTimeout, or requestAnimationFrame, standard web timers are highly imprecise. Over time, your loops will lag, stutter, or slow down completely.

Here is why standard JavaScript timers are unsuitable for high-precision timekeeping, how the Web Audio API solves this, and how to build a drift-free metronome scheduler in vanilla TypeScript.


Why setInterval and setTimeout Drift

To understand why standard timers fail, we have to look at the browser's single-threaded event loop.

When you run setInterval(callback, 500), you are not telling the browser to execute your callback exactly every 500 milliseconds. Instead, you are instructing it to add your callback to the event loop's message queue after 500 milliseconds.

If the browser's main thread is busy executing another script, rendering heavy styles, or handling user interactions, your callback is blocked in the queue.

Furthermore, modern browsers aggressively throttle background tabs to save battery. If a user switches to another tab while running your timer, setInterval intervals can be delayed or throttled to run only once per second. For a musician practicing with a metronome or a developer scheduling network transactions, this timing drift is unusable.


The Solution: Web Audio API Hardware Clock

The browser's native Web Audio API runs on a completely separate, high-priority system-level audio thread. This thread is governed by a dedicated hardware clock that measures time in double-precision floats (audioContext.currentTime) with microsecond accuracy.

Because the audio thread runs independently, it does not suffer from main-thread rendering lag or background tab throttling.

However, we cannot simply run our loop inside the audio thread. Instead, we must use a scheduler/lookahead design pattern (often called the Chris Wilson Scheduler).

The Lookahead Design Pattern

Instead of scheduling audio ticks in real-time, the scheduler looks ahead (e.g., 25ms into the future) and schedules all audio events that need to occur within that window into the AudioContext timeline.

This gives the browser a buffer window to schedule precisely, ensuring perfect, drift-free execution even if the main JavaScript thread is temporarily blocked.


Writing the High-Precision Scheduler in TypeScript

Here is a clean, self-contained TypeScript scheduler that implements this lookahead pattern to play a metronome click:

export class PreciseMetronome {
  private audioCtx: AudioContext | null = null;
  private isPlaying = false;
  private nextNoteTime = 0.0;
  private timerId: any = null;

  private bpm = 120;
  private lookahead = 25.0; // Milliseconds between scheduler runs
  private scheduleAheadTime = 0.1; // Seconds of audio to schedule ahead

  start() {
    if (this.isPlaying) return;

    // Initialize Web Audio Context
    this.audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
    this.isPlaying = true;
    this.nextNoteTime = this.audioCtx.currentTime + 0.05;

    this.scheduler();
  }

  stop() {
    this.isPlaying = false;
    if (this.timerId) {
      clearTimeout(this.timerId);
      this.timerId = null;
    }
    if (this.audioCtx) {
      this.audioCtx.close();
      this.audioCtx = null;
    }
  }

  private scheduler() {
    // Schedule notes as long as we have less than the ahead-time buffer
    while (this.nextNoteTime < this.audioCtx!.currentTime + this.lookahead / 1000 + this.scheduleAheadTime) {
      this.scheduleNote(this.nextNoteTime);
      this.nextInterval();
    }
    this.timerId = setTimeout(() => this.scheduler(), this.lookahead);
  }

  private scheduleNote(time: number) {
    // Create an oscillator (synthesizer) on the fly
    const osc = this.audioCtx!.createOscillator();
    const gain = this.audioCtx!.createGain();

    osc.connect(gain);
    gain.connect(this.audioCtx!.destination);

    osc.frequency.setValueAtTime(800, time); // 800Hz click tone

    gain.gain.setValueAtTime(0.15, time);
    gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.05); // 50ms decay

    osc.start(time);
    osc.stop(time + 0.05);
  }

  private nextInterval() {
    const secondsPerBeat = 60.0 / this.bpm;
    this.nextNoteTime += secondsPerBeat;
  }
}
Enter fullscreen mode Exit fullscreen mode

Generating Perfect Rhythms Privately

When practicing instruments or running audio tests, you need a tool that is responsive and stable, without intrusive advertisements or analytical cookies loading in the background.

To address this, we built a free, 100% Client-Side Interactive Audio Metronome at KandZ Tools.

Our tool operates strictly on your local device. It schedules highly precise visual-audio pulses using the Web Audio API, supports tap-tempo average calculations, and manages your configurations exclusively inside your browser's temporary memory (RAM) with zero server-side tracking.

Test your tempos securely: https://tools.kandz.me/metronome

Top comments (0)