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:
- Reads
audioCtx.currentTime. - Schedules every note whose start time falls inside the window
[currentTime, currentTime + lookahead]onto the audio graph. - 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:
LOOKAHEAD— how far ahead ofcurrentTimeto schedule notes. Too small and a late tick causes a gap; too big and you cannot change your mind (e.g. user hits pause) withoutcancelQueue. Typical: 100–200 ms.SCHEDULE_INTERVAL— how often the timer fires. Must be smaller thanLOOKAHEADor you under-run the buffer. Typical: 25–50 ms.
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:
- Small lookahead (100 ms). Worst case, the listener hears 100 ms of audio after they hit pause — usually acceptable. Pausing means “stop scheduling new notes” plus
osc.stop(audioCtx.currentTime)on the few live ones. - Cancel the whole queue.
audioCtx.suspend()stops time itself, killing scheduled notes without pops.audioCtx.resume()on play — but the clock advanced while suspended, so you must recomputenote.whenagainst the newcurrentTime. A seek scrubber always re-walks score from a new time origin.
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”
- Tab throttling is more aggressive. Background tabs throttle
setTimeoutto 1000 ms andrequestAnimationFrameto zero. You literally cannot keep scheduling from a hidden tab;suspend()the context onvisibilitychangeand resume on focus. AudioWorkletfor intra-block work. If you need sample-accurate DSP (custom synthesis, real-time effects), sub-128-sample timing lives in a worklet. The lookahead scheduler above is still right for events (notes, triggers); the worklet handles what happens within those events.- High-refresh displays. 120/240 Hz screens run
requestAnimationFramefaster, but the audio clock is wall-clock and unchanged. Drive visuals off rAF; schedule audio off the audio clock. Don’t be tempted to schedule audio from rAF — rAF pauses when the tab is hidden.
Takeaways
setIntervalandsetTimeoutare not clocks; they are suggestions. They will fire late by tens of milliseconds under load and freeze entirely in background tabs.audioCtx.currentTimeis the only clock the speaker obeys. It is monotonic, drift-free, and refuses to pause.- The lookahead scheduler: a sloppy timer that wakes up every 25 ms and schedules every note in the next 100 ms onto the audio clock at absolute times. Scheduling lateness becomes inaudible bufferness.
- Always schedule with
setValueAtTime(value, when)againstaudioCtx.currentTime— never withstart()at “now.” Future scheduling is the whole point. - On tab-hidden, call
audioCtx.suspend(); on focus,audioCtx.resume()and recompute future times against the newcurrentTime.
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.