Drums on setInterval Drift — Say "Play at 0.55s", Not "Play Now"
A lookahead scheduler: the JS timer only wakes things up, the audio clock does the timing
To keep time in the browser, write "play at 0.55s" instead of "play now." The JS timer only wakes things up; the sound gets scheduled ahead on the AudioContext clock. That's a lookahead scheduler. After reading this you can build a steady metronome in about 30 lines.
Running the two side by side
While building a "rhythm" chapter for a learning app, I added interference that randomly blocked the main thread for 0–30ms and ran both approaches together. Target: 16th notes at 120 BPM, 125ms apart. Measured in local Chrome on 2026-09-19.
setInterval, play immediately: gaps of 114, 118, 126, 133, 115, 136, 119, 127 ms
lookahead, schedule ahead: scheduled at 0.55, 1.05, 1.55, 2.05, 2.55 … s (logged every 4 steps, exactly 0.5s apart)
To be upfront: the interference was added on purpose. I haven't yet measured how much setInterval wobbles without it. And the setInterval numbers are callback times, not onsets recorded from the speaker. Still, real apps have rendering, GC and other scripts blocking the main thread all the time, so the noisy version is closer to reality.
Why it drifts
setInterval(fn, 125) only promises "I'll call you around 125ms later." If the main thread is busy, the call comes late. If the callback does osc.start(ctx.currentTime), the note plays the moment the callback runs, so every late timer becomes a late beat.
The AudioContext clock (ctx.currentTime) runs on the audio thread. A blocked main thread doesn't push it back. Book a note for "1.55s" in advance and it plays on time even if JS runs a bit late in between.
Minimal code
const ctx = new AudioContext()
const step = 60 / 120 / 4 // 16th note at 120 BPM = 0.125s
let nextTime = ctx.currentTime + 0.05
function click(time) {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
gain.gain.setValueAtTime(0.2, time)
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05)
osc.connect(gain).connect(ctx.destination)
osc.start(time)
osc.stop(time + 0.05)
}
setInterval(() => {
while (nextTime < ctx.currentTime + 0.1) { // notes due in the next 0.1s
click(nextTime) // book them at the exact time
nextTime += step
}
}, 25)
The timer wakes every 25ms, and it's fine if that wobbles. Notes are booked 0.1s ahead, so a timer that's tens of ms late is still inside the buffer. 25ms and 0.1s are the starting values Chris Wilson suggests in "A Tale of Two Clocks." Because of autoplay rules, create the AudioContext inside a button click.
Things that already work this way
| Who decides the sound time | If the main thread is busy | |
|---|---|---|
setInterval + osc.start(ctx.currentTime) |
when the timer fired | plays late |
| hand-rolled lookahead | computed nextTime |
no effect within 0.1s |
Tone.js Transport |
the time passed to the callback |
no effect if you pass time on |
Strudel s("bd*4") |
the library's scheduler | one line does sound, timing and booking |
"Doesn't Tone.js handle all of this?" Yes, and the time your callback receives is exactly this nextTime. Ignore it and play at Tone.now(), and you get the same drift. Library or not, knowing the mechanism is what keeps you from that mistake.
For a one-off sound effect, playing immediately is fine. You need this once sounds follow a beat: drums, sequencers, metronomes.
Still open
In background tabs, browsers slow timers down to roughly once per second (MDN). A 0.1s lookahead can't cover that. I haven't checked yet how libraries get around it (Worker timers or otherwise).
If you're stuck on a browser feature where sound or timing matters, you can ask negabaro. This experiment came out of building our own app.
Key Points
Playing with osc.start(ctx.currentTime) inside a timer callback turns every late timer into a late beat
Wake every 25ms and book the next 0.1s of notes at nextTime
In Tone.js, pass the time argument through. Don't use Tone.now()
A 0.1s lookahead can't cover timer throttling in background tabs