Scheduling Web Audio With Two Clocks
Building precise audio software on the web platform comes down to managing two very different clocks. The Web Audio API exposes an audio clock via the AudioContext object's currentTime property, which is a floating-point number of seconds since the context was created. This clock is tied to the audio subsystem's hardware and is precise enough to align events to individual samples, even at high sample rates—the double-precision format leaves plenty of bits to point to a specific sample even after days of running.
For musical applications—drum machines, sequencers, games, or any rhythmic use of audio—precise timing of events is essential, not just for starting and stopping sounds but also for scheduling parameter changes like frequency or volume. The audio clock handles all of this through the start() and stop() methods on audio nodes, as well as the set*ValueAtTime() methods on AudioParam. This allows scheduling events far in advance with sample-level accuracy. However, scheduling too far ahead becomes a problem when you need flexibility to change tempo, adjust parameters, or halt playback mid-sequence—you'd be stuck with a queue of pre-scheduled events that can only be muted by inserting a gain node and cutting the output.
In short, you don't want to look too far ahead when scheduling audio events, because you may need to change that scheduling entirely based on user interaction or musical decisions.
The JavaScript Clock and Its Limitations
The other clock in play is the JavaScript clock, represented by Date.now() and setTimeout(). On the positive side, JavaScript provides callback mechanisms through window.setTimeout() and window.setInterval() that let the system call back into your code at specified times. But the precision is lacking. Date.now() returns an integer millisecond value, giving at best a one-millisecond resolution. In musical terms, a note starting a millisecond early or late might be imperceptible, but at a standard 44.1 kHz sample rate, that's roughly 44.1 times too coarse for audio scheduling. Dropping any samples in a chain of audio can cause glitches.
While the High Resolution Time specification offers better precision through window.performance.now()—already implemented, albeit prefixed, in many browsers—it doesn't address the real problem with JavaScript timing. The millisecond precision of Date.now() is tolerable; the issue is that callbacks from setTimeout() and setInterval() can be delayed by tens of milliseconds or more due to layout, rendering, garbage collection, XMLHttpRequest callbacks, or any other work happening on the main execution thread.
This is where the architectural advantage of the Web Audio clock becomes clear: audio events are processed on a separate thread. Even if the main thread stalls during complex layout or while paused at a debugger breakpoint, the audio will still fire at the exact scheduled times. The JavaScript thread has no such guarantee.
Why Direct setTimeout Scheduling Fails
Using setTimeout() directly to start audio events is risky because the main thread can be stalled for unpredictable durations. At best, notes will fire within a millisecond or so of their intended time; at worst, they'll be delayed far longer. For rhythmic sequences, the timing becomes inconsistent because it's sensitive to everything else happening on the main thread.
A common question is why audio events don't provide callbacks. While such callbacks could be useful in some contexts, they wouldn't solve this scheduling problem: those callbacks would fire on the main JavaScript thread and would be subject to the same variable delays as setTimeout(). The gap between when an event is scheduled and when it's actually processed would remain unpredictable.
The solution is a hybrid approach that lets JavaScript timers and the audio hardware clock collaborate. Rather than relying solely on one clock, you use JavaScript timers—setTimeout(), setInterval(), or requestAnimationFrame()—as a coarse scheduling mechanism, while delegating precise sample-aligned event timing to the Web Audio API's scheduling methods. This division of labor gives you both the flexibility to adapt to changing musical requirements and the precision needed for glitch-free audio.
Scheduling Audio Ahead of Time
The key to combining user control with precise timing is to stop trying to play notes directly in a timer callback. Instead, the metronome uses a setTimeout() timer merely as a trigger to look ahead and schedule Web Audio events for notes that will need to play in the near future. The timer fires roughly every 25ms, but on each call it schedules all notes that fall within the next 100ms window. This way, even if a timer callback is delayed by main-thread work like garbage collection, layout, or rendering, the audio events have already been queued with the audio hardware.
The overlap is essential. If you only scheduled events up to the exact time of the next timer call, any delay in that call would cause audible dropouts. The scheduling window must also account for the audio system’s own buffering latency, which can vary from a few milliseconds to around 50ms depending on the operating system and hardware. A larger lookahead window makes the application more resilient to interruptions, but it also means that real-time changes—like tempo adjustments—take effect less immediately. A 100ms lookahead with 25ms intervals is a reasonable starting point, but you should tune both values based on how busy the main thread is and how tight you need the control response to be.
The timing diagram below shows what this looks like in practice with the metronome demo running at a high tempo. Notice that even when a setTimeout() callback is delayed by roughly 50ms in the middle of the sequence, the scheduled audio events continue without a gap:
It’s also common for a single scheduling call to place multiple notes. If you use a longer scheduling interval, such as a 250ms lookahead checked every 200ms, each call may need to schedule several upcoming events, particularly if you increase the tempo mid-playback:
This pattern extends naturally beyond a simple metronome. A drum machine frequently needs to trigger multiple simultaneous sounds, and a sequencer may have irregular intervals between notes; the process of checking the current audio time and scheduling everything due before the next check remains the same.
The core scheduling logic in the demo is contained in the scheduler() function. It grabs the current audio hardware time and compares it against the time for the next note in the sequence. Most of the time it finds no notes waiting to be scheduled; when it does, it calls scheduleNote() and advances to the next one:
while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
scheduleNote( current16thNote, nextNoteTime );
nextNote();
}
The scheduleNote() function creates the actual audio event. In this case, it uses oscillators to generate beeping sounds at different frequencies, but you could just as easily use AudioBufferSource nodes to play drum samples or other sounds:
currentNoteStartTime = time;
// create an oscillator
var osc = audioContext.createOscillator();
osc.connect( audioContext.destination );
if (! (beatNumber % 16) ) // beat 0 == low pitch
osc.frequency.value = 220.0;
else if (beatNumber % 4) // quarter notes = medium pitch
osc.frequency.value = 440.0;
else // other 16th notes = high pitch
osc.frequency.value = 880.0;
osc.start( time );
osc.stop( time + noteLength );
Once an oscillator is scheduled and connected to the output, the code can forget about it. The oscillator starts, stops, and is eventually garbage-collected without any further management.
The nextNote() method advances the sequence state by setting nextNoteTime and current16thNote to the following note:
function nextNote() {
// Advance current note and time by a 16th note...
var secondsPerBeat = 60.0 / tempo; // picks up the CURRENT tempo value!
nextNoteTime += 0.25 * secondsPerBeat; // Add 1/4 of quarter-note beat length to time
current16thNote++; // Advance the beat number, wrap to zero
if (current16thNote == 16) {
current16thNote = 0;
}
}
This is straightforward, but it relies on an important design choice: the code does not track "sequence time" from the start of playback. It only remembers when the last note was played and calculates when the next one should occur. That makes it trivial to change tempo or stop playback at any point.
This collaborative scheduling technique is already used in several known Web Audio projects, including the Web Audio Drum Machine, the Acid Defender game, and the Granular Effects demo.
A Separate Clock for Graphics
While audio events are scheduled against the audio hardware clock, visual updates belong on a third timing system: the refresh rate of the display, accessed through requestAnimationFrame(). For a simple metronome that draws boxes, this might seem like overkill, but for dense, synchronized graphics such as a music notation display, syncing to the visual refresh rate is critical for smoothness.
The metronome keeps track of the notes it has scheduled in the scheduler() function:
notesInQueue.push( { note: beatNumber, time: time } );
The draw() method, called via requestAnimationFrame() whenever the graphics system is ready, checks the audio system’s clock to decide whether a new beat needs to be drawn:
var currentTime = audioContext.currentTime;
while (notesInQueue.length && notesInQueue[0].time < currentTime) {
currentNote = notesInQueue[0].note;
notesInQueue.splice(0,1); // remove note from queue
}
Even in the graphics callback, the audio clock is the reference point, because it is the one that actually determines when sounds play. The timestamps from requestAnimationFrame() are not used for scheduling decisions.
You could collapse the system back down to two timers by putting the note scheduler directly into the requestAnimationFrame() callback. That is an acceptable approach, but note that requestAnimationFrame() would merely be standing in for setTimeout(); the actual scheduling accuracy still has to come from the Web Audio timing APIs.



