DEV Community

NICK
NICK

Posted on

setInterval can't keep time: building a metronome that doesn't drift

Open a metronome built with setInterval, set it to 120 BPM, leave it running for two minutes, then play a click track next to it. They will not still be together.

This is not a bug in the metronome. It is setInterval doing exactly what it promises, which is "run this callback roughly every N milliseconds, if you can." A metronome needs something much stronger: "produce a sound at this exact point in time, no matter what else the page is doing."

I ran into this when building DrumDesk, a couple of browser-based practice tools. The fix is older than most of the frameworks I use, and it is worth writing down because the wrong version sounds almost right — which is worse than sounding obviously broken.

The three ways setInterval loses

1. The main thread owns it. setInterval callbacks go into the same queue as your event handlers, layout, garbage collection, and third-party scripts. If the browser spends 180ms doing layout, your click is 180ms late. It does not get rescheduled to catch up — it just arrives late, and then the next one arrives on the new, slightly wrong schedule.

2. Background tabs get throttled. Browsers clamp timers in inactive tabs — historically to 1 second, and still to at least a few hundred milliseconds in most cases. A user who switches to their email to look up a tempo marking comes back to a metronome that is no longer a metronome.

3. Errors accumulate. setInterval(fn, 500) does not mean "call fn every 500ms of real time." It means "wait at least 500ms between calls." Every delay is added to the timeline permanently. Small delays are the norm, so the drift is monotonic — it only ever goes one way.

The third one is what makes it insidious. A metronome that is 8ms fast sounds fine. A metronome that gets 8ms slower every bar is a different instrument after a minute.

Two clocks, and only one of them can be trusted

The thing that changed my approach: Web Audio has its own clock.

AudioContext.currentTime is driven by the audio hardware, not by the main thread. It advances in real time even when your JavaScript is blocked, because the audio pipeline runs on a separate real-time-priority thread. If the main thread stalls for 200ms, currentTime keeps moving and the sounds you already scheduled still fire exactly when you scheduled them.

So the architecture becomes:

  • A slow, sloppy timer that periodically wakes up and asks "do I need to schedule more sounds yet?" It is allowed to be late. Nobody hears it.
  • The audio clock that decides exactly when each sound plays. It is never late.

This is the pattern Chris Wilson described as "A Tale of Two Clocks" in 2013, and it is still the answer.

The scheduler

The trick is a lookahead queue. Every 25ms, look 150ms into the future and schedule everything that falls inside that window. Then go back to sleep.

var nextTime = ctx.currentTime + 0.05;   // when the next note should sound
var LOOKAHEAD = 0.15;                    // seconds of future to pre-schedule
var INTERVAL = 25;                       // ms between wake-ups

function schedule() {
  while (nextTime < ctx.currentTime + LOOKAHEAD) {
    click(nextTime, /* isRightHand */ true, 0.35, false);
    nextTime += stepSeconds();           // one subdivision
  }
  timer = setTimeout(schedule, INTERVAL);
}
Enter fullscreen mode Exit fullscreen mode

Two numbers, two different jobs:

  • INTERVAL = 25 can drift, jitter, or arrive 60ms late. It does not matter, because its only job is to keep the buffer full.
  • LOOKAHEAD = 0.15 is the safety margin. Any main-thread stall shorter than 150ms is completely absorbed. A stall longer than that will produce a gap — but that is a genuinely broken page, not a busy one.

The while loop is the important part. If the timer fires late, the loop runs several times in one go and catches up. Compare that to setInterval, which can only ever fire once per tick and therefore falls further behind forever.

Making a click that doesn't sound like a click

Scheduling a sample-accurate start time is half of it. The other half is making the sound not be a pop.

A bare oscillator started and stopped abruptly produces a discontinuity in the waveform, which you hear as a click — an ugly one, distinct from the click you actually wanted. The fix is a fast envelope:

function click(at, isRight, vol, long) {
  var dur = long ? 0.16 : 0.07;
  var osc = ctx.createOscillator();
  var g = ctx.createGain();

  osc.type = 'triangle';
  osc.frequency.setValueAtTime(isRight ? 1180 : 790, at);

  g.gain.setValueAtTime(0.0001, at);                       // start near zero
  g.gain.exponentialRampToValueAtTime(vol, at + 0.004);    // 4ms attack
  g.gain.exponentialRampToValueAtTime(0.0001, at + dur);   // decay to silence

  osc.connect(g);
  g.connect(ctx.destination);
  osc.start(at);
  osc.stop(at + dur + 0.01);                               // small tail
}
Enter fullscreen mode Exit fullscreen mode

Three details in there are worth stealing:

It starts at 0.0001, not 0. exponentialRampToValueAtTime cannot ramp to or from zero — a zero target throws, and a zero start makes the ramp mathematically meaningless. 0.0001 is the usual idiom. You will find this out the hard way otherwise.

The oscillator is stopped slightly after the envelope ends. dur + 0.01 gives the gain node time to finish ramping before the source dies. Without it you can get a truncated tail.

Right and left hand get different pitches. 1180 Hz against 790 Hz is roughly seven semitones apart — far enough to hear which hand is which, close enough that it still reads as one instrument. For rudiment practice this matters more than any visual notation: you hear a wrong sticking before you see it.

The visual can lag. It can't disagree.

Once the audio is sample-accurate, the UI becomes the problem. If you highlight the current beat inside the scheduler loop, the highlight happens ~150ms before the sound — because you scheduled it, you did not play it.

The fix is to keep the visual on a separate timer that fires when the note is actually due:

setTimeout(function () {
  renderSticking(i);
}, Math.max(0, (at - ctx.currentTime) * 1000));
Enter fullscreen mode Exit fullscreen mode

A visual that is 20ms behind the audio is invisible. A visual that is 150ms ahead makes the whole tool feel broken, even though the audio is perfect. Humans forgive latency; they do not forgive contradiction.

One more thing: the context starts suspended

Browsers will not let you make noise before a user gesture. new AudioContext() creates a context in suspended state, and every start() on a suspended context is silently dropped.

if (ctx.state === 'suspended') { ctx.resume(); }
Enter fullscreen mode Exit fullscreen mode

Call it on every play action rather than once at load — the context can be suspended again after a tab has been backgrounded, and "the metronome works until I switch tabs" is a confusing bug report to receive.

Why bother with the browser at all

Because the alternative is asking someone to install an app to hear a click.

The whole premise of these tools is that a drummer sits down for ten minutes, wants a metronome and a subdivision grid, and should not have to create an account, download 40MB, or watch a splash screen first. Browser audio was "not good enough" for long enough that the assumption stuck around. With a lookahead scheduler it is sample-accurate, and the latency budget belongs to the audio thread rather than to whatever else the page is doing.

The tuner next to it uses the same clock for a different job: f = base × (A4 / 440) × 2^(cents / 1200), which is boring, correct, and instantly verifiable against a reference tone.

If you want to hear the result rather than read about it, the rudiment trainer is here — 40 rudiments, left and right hand at different pitches, 40–220 BPM, subdivisions, and a random mode. No account, no install, nothing stored. It exists because the setInterval version I wrote first was, to put it kindly, not a metronome.

Top comments (0)