I play fast music, and most metronomes I tried stop at 240 or 300 BPM. So I built one that goes to 1000: Unlimited Metronome. It is free, needs no account and works offline. This post is about the parts that turned out to be harder than "play a click every N milliseconds".

Why setInterval is not a metronome

The obvious version is a timer that plays a sound on every tick. It sounds fine at 80 BPM and falls apart as soon as anything else happens on the page: timers drift, the main thread is busy rendering, and browsers throttle timers in background tabs to once a second or less. At 260 BPM a beat is 230 ms long, so even a 20 ms wobble is audible.

The fix is the pattern from Chris Wilson's article "A Tale of Two Clocks": keep two clocks with different jobs.

  • A coarse JavaScript timer wakes up every 25 ms. It never plays anything itself.
  • On every wake-up it looks a short window ahead (150 ms) and schedules every click that falls inside that window on the audio clock, with AudioBufferSourceNode.start(time).
private tick(): void { const now = this.engine.currentTime const horizon = now + this.lookahead() while (this.sequencer.peekTime() < horizon) { const tick = this.sequencer.next() const interval = 60 / tick.bpm / tick.pulses // A tick more than 50 ms in the past means the timer stalled: // skip its sound instead of playing a burst of late clicks. if (tick.time >= now - 0.05 && tick.kind !== 'mute') { this.engine.scheduleClick(tick.kind, tick.time, interval) } } } 

The audio clock is sample-accurate, so the timer's jitter no longer matters: it only has to wake up before the window runs out. The UI (the beat dots, the bar counter) reads from what has been scheduled and never touches this loop.

Keeping time in a background tab

Musicians switch to a tab with sheet music or a video and expect the click to keep going. Two things make that work:

  1. The timer runs in a dedicated Web Worker. Browsers throttle main-thread timers of hidden tabs hard; a worker is throttled far less. If workers are unavailable it falls back to setInterval.
  2. The look-ahead grows while the page is hidden, from 150 ms to 1.2 s, so even a late wake-up still finds clicks already queued on the audio clock.

Clicks that do not pile up at 1000 BPM

At 1000 BPM with sixteenth-note subdivision there are 66 clicks per second, about 15 ms apart. A click sample longer than that overlaps the next one and turns into mush.

The clicks are not samples at all. Each one is synthesised on the device into an AudioBuffer (a short tone with a few partials, an exponential decay and a little filtered noise in the attack), and every click is rendered no longer than 90% of the time until the next one. The rendered buffers are cached by length in 5 ms buckets, so the cache stays small even when the tempo changes all the time.

A speed trainer that changes tempo on the bar line

The feature I wanted most was a trainer: start at 220, go up by 1 BPM every 4 bars until 240. The rule that made it feel right is that tempo changes are only evaluated on bar boundaries. The sequencer knows which bar and stage it is in, and a new stage always starts exactly on beat one: no extra click, no missing one, no tempo change in the middle of a bar.

Because the sequencer is plain TypeScript with no audio in it, it is easy to test: give it a plan and a start time, pull ticks, and check that the first tick of every stage lands on a downbeat with the new tempo.

Odd meters

Time signatures go from 1 to 32 beats, and beats can be grouped, so 7/8 can be counted as 2+2+3 or 3+2+2. The first beat of each group gets an accent, and any beat can be switched between accent, normal and mute.

The rest of the stack

  • React for the UI, with all of the timing outside React.
  • Every page is prerendered to static HTML for each of the 9 languages and served from Cloudflare Pages.
  • A service worker precaches the app, so after the first visit it works offline and can be installed as a PWA.
  • No account and no cookies. Settings stay in the browser's local storage.

If you try it, I would love to hear how the timing feels on your device at very high tempos, and what is missing: unlimitedmetronome.com.