Every JavaScript metronome, drum machine, DAW, and rhythm game tutorial eventually hits the same wall: setInterval(playClick, 500) clicks. Not crisply — they physically click, audibly off the beat, by tens of milliseconds, the moment the tab loses focus or the GC runs a collection. The browser’s timer is a polite suggestion, not a clock. Audio is not.

This post walks through why this happens and the production-grade fix — the lookahead scheduler — that every accurate audio app uses, in plain code. It builds on Chris Wilson’s canonical “A Tale of Two Clocks” from 2014 but with the patterns that actually ship a decade later, after AudioWorklet, tab throttling, and high-refresh-rate displays changed the rules.

Why setInterval drifts

setInterval runs on the mainthread UI task queue. It is throttled by the browser when the tab is backgrounded (often to one tick per second), it is coalesced with other timers, and it competes with every other piece of JavaScript on the page for runtime. A setInterval(cb, 500) actually fires anywhere from 505 to 1500 ms later.

If your callback says “play this note now,” “now” is the wrong moment by the time it fires — and worse, you have no idea what the right moment was because nothing told you. You can read performance.now() inside the callback, but the audio has already been queued late — you cannot refund elapsed time to a sound that has not yet been triggered.

The two-clock insight

Web Audio has a clock that does not drift: audioCtx.currentTime, in seconds, monotonically increasing, hardware-tied. It keeps ticking while the tab is hidden. You can schedule a sound to play at an absolute future time and it will fire precisely then, regardless of what the mainthread is doing.

The fix, therefore, is to decouple scheduling from playback — use the sloppy main-thread timer only to look ahead into the score and queue future audio events onto the precise audio clock. The browser still fires your timer late, but you already told the audio graph when to play each note in absolute terms, so lateness in scheduling does not become lateness in playback.

The lookahead scheduler

The pattern is a self-rescheduling timer that, every tick:

  1. Reads audioCtx.currentTime.
  2. Schedules every note whose start time falls inside the window [currentTime, currentTime + lookahead] onto the audio graph.
  3. Advances a cursor so each note is only scheduled once.

The timer can fire late by 20 ms and nothing audible breaks — the entire window is 100 ms of future notes; you simply catch up next time. Two constants control the trade-off:

var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var LOOKAHEAD = 0.100;          // seconds — schedule notes up to 100 ms ahead
var SCHEDULE_INTERVAL = 0.025;  // seconds — wake up every 25 ms
var nextNoteTime = 0;           // absolute audio-context time of next note
var notes = [ /* {when: seconds, freq: 440, dur: 0.5}, ... */ ];
var idx = 0;
var timer = null;

function scheduler() {
  // Queue every note inside the Lookahead window
  while (idx < notes.length && notes[idx].when < audioCtx.currentTime + LOOKAHEAD) {
    scheduleOsc(notes[idx]);
    idx++;
  }
  if (idx >= notes.length) { stop(); return; }
  timer = setTimeout(scheduler, SCHEDULE_INTERVAL * 1000);
}

function scheduleOsc(note) {
  var osc = audioCtx.createOscillator();
  var gain = audioCtx.createGain();
  osc.frequency.value = note.freq;
  osc.connect(gain).connect(audioCtx.destination);
  // Attack/release envelope, scheduled at absolute context time
  gain.gain.setValueAtTime(0, note.when);
  gain.gain.linearRampToValueAtTime(0.8, note.when + 0.005);   // 5 ms attack
  gain.gain.setValueAtTime(0.8, note.when + note.dur - 0.01);
  gain.gain.linearRampToValueAtTime(0, note.when + note.dur);  // 10 ms release
  osc.start(note.when);
  osc.stop(note.when + note.dur + 0.01);
}

function start() { nextNoteTime = audioCtx.currentTime + 0.1; scheduler(); }
function stop()  { clearTimeout(timer); audioCtx.close(); /* or cancelScheduledValues */ }

That is the engine of every accurate JS metronome, sequencer, and rhythm game. No live start() calls — every audio event has been pre-scheduled onto the audio clock, which is the only clock the speaker obeys.

The crucial detail: envelope ramps at absolute times

Notice gain.gain.setValueAtTime(0, note.when), not start() with no time argument. The audio params accept an absolute context time. If you call setValueAtTime(0, audioCtx.currentTime) you are back to playing “now,” which is just setInterval with extra steps. The whole point is to give the param a future time you computed from your score, not from when the timer happened to fire.

The same applies to envelope ramps: linearRampToValueAtTime takes an absolute context time, so the attack and release are scheduled against the audio clock, immune to main-thread latency.

Pause, seek, and the cost of lookahead

Lookahead means future notes are committed to the audio graph before they are audible. That is fine until the user pauses or jumps. Two options:

For a falling-notes rhythm game the same pattern works: the visual timeline (falling notes) is rendered from performance.now(), but each note’s audio is scheduled against audioCtx.currentTime at advance time. Audio never glitches; visuals chase it.

What changed since “A Tale of Two Clocks”

Takeaways

You can hear this exact pattern driving the auto-left-hand and pedal engine on the piano game: every note, harmony, and sustain-pedal change is scheduled 100 ms in the future, so the music stays tight even when the browser’s mainthread is busy re-rendering the falling-notes canvas.