I built a metronome. On paper this is a beginner project. You play a click, you wait, you play it again. The whole thing is a loop with a delay in the middle. I thought I would be done in an afternoon.
Then I actually listened to it, and the clicks wandered. Not by a lot, but enough that a musician would feel it. At 130 BPM you have a click every 461 milliseconds, and the human ear can notice timing errors well under 10 milliseconds. My clicks were drifting by 20, 30, sometimes 50 milliseconds. It felt drunk.
The clock everyone reaches for first
My first version looked like what everyone writes. Schedule the next click with setTimeout, measure the time with Date.now(), correct as you go. It reads fine. It also does not work, for two separate reasons that took me a while to untangle.
The first reason is that setTimeout is not a promise about when your code runs. It is a promise about the earliest your code is allowed to run. The browser has one main thread, and if that thread is busy laying out the page, running a bit of React, or garbage collecting, your timer sits in a queue and waits its turn. Ask for 461 milliseconds and you might get 468, or 475, or worse if the tab is doing anything at all.
The second reason is the one that surprised me. Date.now() is not a stopwatch. It is a reading of the wall clock, the same clock your operating system syncs over the network. That clock can jump. It can be nudged forward or backward by NTP corrections. It can slew. On some machines it goes backward. If you are measuring intervals by subtracting two Date.now() values, you are trusting that the wall clock did not move underneath you between the two readings, and that is a trust it has not earned.
The clock you actually want
The browser has a second clock built for exactly this. performance.now() is monotonic. It only ever moves forward, it is not affected by system time changes, and it has sub-millisecond resolution. If you are measuring how much time passed, this is the number you want, always. I have stopped using Date.now() for anything except displaying a date to a human.
But switching clocks was not enough on its own, because the real problem was never the measurement. It was the scheduling. Even a perfect clock does not help if the thing that fires your click is stuck behind everything else on the main thread.
Scheduling ahead of time
The fix that finally made it feel solid came from an idea I borrowed from how audio software has done this for years. Do not schedule one click and wait. Instead, look a little way into the future, and schedule every click that should happen in the next slice of time, right now, with exact timestamps. The Web Audio API lets you say play this sound at exactly this moment, and it honors that at the audio hardware level, not the main thread level.
So the loop changes shape. A cheap timer wakes up every so often, not to play anything, but to ask a question. Which clicks fall inside the next window, and have I scheduled them yet? If not, schedule them now with their precise start times. The timer being late by a few milliseconds no longer matters, because it is not the thing playing the sound. It is just the thing that refills the queue. As long as it refills the queue before the queue runs dry, the audio stays perfect.
To keep that timer from ever blocking the audio, I moved it off the main thread entirely and into a Web Worker. A Worker runs on its own thread, so page layout and rendering cannot delay it. The Worker does nothing but tick and post a message that says time to refill. The main thread hears that and schedules the next batch. It is a small amount of code and it removed an entire class of glitches.
What I took away from it
The lesson generalizes past metronomes. Any time your code says do this again in exactly N milliseconds, stop and ask what happens when the machine is busy, and what clock you are measuring against. Most of the time the answer is that a small drift does not matter and you move on. But when it does matter, the pattern is the same. Measure with a monotonic clock, schedule ahead of the moment instead of at the moment, and keep the thing that does the scheduling off the thread that does the heavy work.
Date.now() is a fine way to ask what day it is. It is a bad way to ask how much time just passed. Those feel like the same question until the day they very much are not.
