Simple things are complex
I started making a bansuri app with only two features: a metronome and a tuner. People in my bansuri class always seem to struggle to find decent ones, so the idea was an app with no logins and no ad popups and no AI and no chatbots and no freemiums and no paywalls and no download-our-app and no endless-loading and no broken-screen-on-phones and no weird-buggy-features and no buy-my-course and no follow-me-like-me-share-me BS.
I wanted to quickly build something you can use and forget about once you don’t need it. But also would very much come back to because you like it and it’s a part of your practice now.
The metronome seemed like a 10 minute feature: a React component with a setInterval shoved inside a useEffect, playing a click through the browser’s audio API. But as I started writing it, I remembered that setInterval and setTimeout are unreliable when you need precise timing, and music needs exactly that. They promise a minimum delay, not an exact one. When the timer expires, your callback is placed in the task queue and has to wait for the call stack to clear. If the main thread is busy with heavy JavaScript, layout, or other callbacks, your callback runs late. With setInterval, every tick lands a little early or a little late, and ticks can bunch up or get skipped entirely when a callback runs long.
Now, of course, my app is tiny. With only 2 features that never run together, it’s hard to imagine the main thread blocked scenario here. But my mind likes to wander. I asked: What if?
A Web Worker looks like a partial fix. It runs on a separate thread, so its timer isn’t held up by whatever the main thread is doing. But a worker can’t play sound by itself. It has to send a message back to the main thread, and that message waits in the same queue as everything else. The unpredictable delay comes right back.
The fix is to stop asking a main-thread timer to decide when a beat plays. The Web Audio API keeps its own clock, audioContext.currentTime, driven by the audio hardware, and you can tell it to play a sound at an exact future moment on that clock. The audio thread runs at high priority and plays it right on time. So the pattern, from Chris Wilson’s A Tale of Two Clocks, is a lookahead scheduler: a loose, sloppy timer wakes up every 25 ms or so and queues every beat that falls in the next 100 ms on the audio clock. The timer is allowed to be late. The beats aren’t, as long as they were queued in time.
What if the system is busy but the app isn’t?
Not much of a problem. Audio threads run at high priority on the CPU, and if a small lag does happen, the lookahead absorbs it.
What about a low-end or thermally throttled phone?
Still fine. A metronome click is about the cheapest sound you can produce.
What if I’m wearing Bluetooth earphones, which add a delay between when the sound is made and when I hear it?
That delay is roughly the same on every beat, so every click arrives late by the same amount. What matters is the time between the beats, not the exact moment each one lands.
What if the app is busy but the system isn’t?
Say the lookahead is 50 ms. If the main thread is blocked for less than that, nothing goes wrong. The upcoming beats are already queued on the audio clock, and they play on time no matter how busy the main thread is.
And if the main thread is blocked for longer than the lookahead?
This is where the bugs live. The beats already queued still play. But the scheduler doesn’t wake up in time to queue the ones after them, so the queue runs dry and you get a gap of silence. When the thread finally wakes up, the next beat’s time is already in the past. The scheduling loop queues every missed beat at once, Web Audio plays anything scheduled in the past immediately, and you get a burst of rapid clicks. A simple fix is to skip any beat whose time has already passed and pick the rhythm back up from the next beat still in the future.
So why not just use a bigger lookahead?
That seems like the obvious answer: make it big enough to swallow any weird scenario.
How far can I stretch the lookahead time?
Around 100 ms is common practice, because a lookahead that’s too long causes problems of its own. Stopping is manageable, since you can cancel clicks you’ve already scheduled. A tempo change is harder: the beats already queued still play at the old tempo. The musician might frown for a moment, sensing that something weird happened without being able to say what, like the character in a horror movie who’s about to die, walking through a dark room, while a shadow passes behind.
What if I switch tabs while the metronome is playing? Chrome throttles timers in background tabs to once per second.
Not a problem. Chrome doesn’t apply that throttling to tabs that are playing audio, and a ticking metronome is playing audio.
Doesn’t a Web Worker save us from a busy main thread?
Not really. The worker’s timer stays accurate, but the message it sends still has to wait in the main thread’s queue. Monica Dinculescu tested exactly this in Metronomes in JavaScript: with heavy work blocking the main thread, her Worker-based metronome ticked just as late as the plain setInterval one, while beats prescheduled on the Web Audio clock stayed on time. Where a worker does help is as a place to move heavy work, so the main thread stays free.
I had many more questions, but I stopped digging and climbed out. It was only a 10 minute task, after all.
For now, the conclusion is: don’t ask the busy thread to keep time. Let it write the beats down a little ahead, and let the audio clock play them.
Every simple thing hides a million layers underneath. Those layers come from different eras, sometimes built by people you have never met, in places far, far away, who maybe never spoke your language or saw someone of your color. The world was built one step at a time, and each step has its limits. Those limits show up in the edge cases, and the world is full of edge cases. The best AI models still carry human biases. History is still told mostly from the side of the people who won the wars and burned the books that told a different story. Phishing still works as well as it does partly because email runs on SMTP, which had no way to check who actually sent a message.
This reminds me of an old but beautiful thought experiment: No vehicles in the park. A seemingly simple rule. Right? Well, until an ambulance needs to get in, or a kid on a bicycle, or someone who wants a tank as a war memorial.
What began as a 10 minute Metronome building exercise turned into 10 hour research, which turned into a philosophical death spiral. I’m still staring at the code editor.