About

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 now" osc.start(ctx.currentTime) inside the timer callback gaps: 114, 118, 126, 133, 115, 136, 119, 127 ms lookahead: "play at 0.55 s" osc.start(nextTime), timer only wakes the scheduler small marks = 25 ms timer wake-ups (they can drift, it does not matter) dashed lines = target grid, 125 ms (16th notes at 120 BPM)
  • 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

1

Playing with osc.start(ctx.currentTime) inside a timer callback turns every late timer into a late beat

2

Wake every 25ms and book the next 0.1s of notes at nextTime

3

In Tone.js, pass the time argument through. Don't use Tone.now()

4

A 0.1s lookahead can't cover timer throttling in background tabs

Use Cases

Browser metronomes, drum machines and step sequencers Syncing visuals and sound in rhythm games
← event.target vs currentTarget — The Bug That Only Fired on the Icon Stroke "Left Ear Only" Means Two Things — StereoPanner Moves, ChannelSplitter Picks →