Web Audio Scheduling Drift: Right in Code, Late in the Room

A Web Audio metronome on setTimeout drifts because the JavaScript timer and the audio clock are two clocks. A lookahead scheduler on currentTime fixes it, measured before and after.
On 10 October 2026 I played a Web Audio metronome from setTimeout on a 125 ms grid and measured where each click actually started. Under main-thread stalls of 150 to 300 ms, the worst click started 2,798 ms off the grid. Every timer callback had fired and every start() call was correct. That is Web Audio API scheduling drift: the code looks right and the beat is late.
Every figure here comes from headless Chromium 151 on a server with a fake audio device, so the numbers show a mechanism, not what a listener hears on real hardware. My own Web Audio apps have played out of time in use: AudioViz is raw Web Audio from 2022 and Psychedelica is Tone.js from 2024. Re-measuring both turned up a bug in each.
TL;DR: Web Audio API scheduling drift comes from starting sounds inside setTimeout callbacks, because the JavaScript timer and AudioContext.currentTime are two clocks. Queue notes ahead on currentTime from a short timer loop instead. In my headless Chromium 151 test, the setTimeout metronome landed up to 2,798 ms off its grid under stalls, and the scheduled one 0.01 ms off when idle.
Why Does setTimeout Cause Web Audio API Scheduling Drift?
A setTimeout metronome drifts because the timer runs on the main thread, where layout, rendering, garbage collection and other callbacks delay it, and the sound starts whenever the callback finally runs. The audio hardware clock behind AudioContext.currentTime is not delayed by any of that, so each late callback becomes a late sound.
Chris Wilson's "A Tale of Two Clocks" on web.dev (last updated 2013-01-09) says timer callbacks "can easily be skewed by tens of milliseconds or more," and that "even if you're stopped at a breakpoint in the debugger, the audio thread will continue to play scheduled events." It reports no measured drift, so I built a metronome that plays a 10 ms click on a 125 ms grid (8 notes a second) four ways:
setInterval, start now: each callback callssource.start()with no time, which MDN says starts playback immediately.- Chained
setTimeout, start now: the same, with each callback scheduling the next. - Lookahead on a
setTimeouttick: Wilson's pattern, a 25 ms tick that queues every note due in the next 100 ms. - Lookahead on a Web Worker tick: the same scheduler, ticked by a worker
setInterval(25).
Timer logs say nothing about when a sound played, so an AudioWorklet onset detector records the sample frame where each click rendered. Grid error is that start time minus an ideal 125 ms grid anchored at the first note. Load is a seeded main-thread busy-wait: idle adds none, busy blocks 30 to 80 ms every 50 to 150 ms, stall blocks 150 to 300 ms every 0.8 to 1.6 s. Each cell is 3 runs of 160 notes.
Chained setTimeout accumulates, because each callback's delay is added to the next one's start. By performance.now(), its callbacks ran up to 265 ms late within a 20 s idle run, 2.2 s late under busy load and 2.9 s late under stalls. Why a main-thread timer is late on a throttled phone is the same cost made visible; here it is audible. In AlgoViz, a sound started straight from a comparison callback takes the callback's time as its time: fine for a 0.1 s beep, wrong for a sound that has to stay on a grid.
How Does AudioContext.currentTime Differ From performance.now()?
AudioContext.currentTime is the audio rendering clock. It advances in render quanta of 128 sample frames (about 2.9 ms at 44.1 kHz) and stops while the context is suspended. performance.now() is the system clock that timers use. The W3C spec says the audio timeline "may not be synchronized with other clocks in the system."
The symptom pointed at the timer, and the timer was fine. In the idle setInterval runs, callbacks stayed within 4 ms (p95) of schedule by performance.now(), yet the beat was 318 to 984 ms per minute off by the audio clock across three runs. A log of timer callbacks reported a healthy metronome.
In a separate 10 s probe, currentTime gained 9,810 ms while performance.now() gained 10,001 ms. That is the fake device of a server with no sound card. I did not measure a real output device's clock rate and found no source for one, so read it as two clocks disagreeing, not a figure for browsers. Web Audio API scheduling drift is a two-clock problem before it is a scheduling problem.
The screen is a third clock, and Wilson's article says visual display should use "a THIRD timing system," requestAnimationFrame. A beat flash driven by currentTime fires early, because currentTime runs ahead of what the speakers are playing. getOutputTimestamp() (MDN: Baseline Widely available since April 2021) returns a contextTime and performanceTime pair that maps the output position onto performance.now() units:
function outputPosition() {
const { contextTime, performanceTime } = ctx.getOutputTimestamp();
return contextTime + (performance.now() - performanceTime) / 1000;
}In a 500 ms-grid test (40 notes, 3 runs), currentTime ran 41.3 ms ahead of that position on average, and a flash driven by currentTime fired about 33 to 35 ms (median) before Chromium's estimate of the sound leaving the device. Chromium reported baseLatency 0.01 s and outputLatency 0.032 s. Those are the fake device's reported numbers, not an acoustic measurement.
A real-time Web Audio analyser running every frame is where all of this coexists in my code. AudioViz's visualizer schedules nothing: each requestAnimationFrame callback reads whatever the analyser holds at that moment. Its waveform view had a bug I only saw by measuring. draw() in src/helpers/waveform.js re-requests a frame unconditionally, every Start begins a new loop, and Stop does not cancel it. On the live site, three Start/Stop cycles left 1.0, 1.0, 2.0, then 3.0 draw requests per displayed frame. The fix, so that Stop ends the loop, is committed and ships with the next deploy.
How Does a Lookahead Scheduler Survive Main-Thread Stalls?
A lookahead scheduler wakes on a short timer, here every 25 ms, and queues every note due within the next 100 ms with source.start(when) on the audio clock. The audio thread plays each note at its own time, so a late timer only matters if it is later than the lookahead.
The values are Wilson's: "A good place to start is probably 100ms of 'lookahead' time, with intervals set to 25ms." Here is the scheduler from my demo, with an oscillator in place of the 10 ms click buffer:
const ctx = new AudioContext();
const GRID = 0.125; // seconds between notes
const LOOKAHEAD = 0.1; // seconds to queue ahead of currentTime
const TICK = 25; // ms between scheduler wake-ups
let first = 0; // audio time of note 0
let next = 0; // index of the next note to queue
function click(when) {
const osc = ctx.createOscillator();
osc.connect(ctx.destination);
osc.start(when);
osc.stop(when + 0.01);
}
function tick() {
// Fixed grid, never "previous note + GRID": a late tick cannot push later notes back.
while (first + next * GRID < ctx.currentTime + LOOKAHEAD) {
click(first + next * GRID);
next++;
}
setTimeout(tick, TICK);
}
function startMetronome() { // call after ctx.resume() has resolved
first = ctx.currentTime + 0.05;
tick();
}The loop compares against ctx.currentTime, never performance.now(), so scheduler and sound share one clock. Each note time is first + i * 0.125, so an error in one tick cannot accumulate into the next.
I learned this on raw Web Audio with AudioViz, then moved to Tone.js for Psychedelica, whose Transport is a lookahead scheduler too. In Tone.js 15.0.4, which Psychedelica's lockfile resolves, the context defaults are clockSource: "worker", lookAhead: 0.1 and updateInterval: 0.05, Tone.now() returns currentTime + lookAhead, and Transport.start() calls context.resume(). Psychedelica, scheduling twelve scenes of generative audio, sends all of its sound through it.
What Did the Psychedelica Drum Loop Get Wrong?
The drum loop in createDrumLoop() added Tone.now() to a time that was already absolute, so the snare and hi-hat were queued at roughly twice the current audio time. Tone.Loop passes its callback a time on the audio clock. The kick used it as given, bass and melody used time + offset, and snare and hi-hat used Tone.now() + time + offset.
I found it by instrumenting start() on the live site, pressing Proceed and watching for 65 s. Of 580 start() calls with a time, 550 led currentTime by at most 1 s (median 0.091 s, Tone's lookahead working). The other 30 fit when = 2.0008 x currentTime + 0.33 s, with a largest residual of 0.47 s. One was queued at 30.48 s to play at 61.32 s. The lead grows with the length of the session.
// before: time is already an absolute audio-clock time
snare.triggerAttackRelease("8n", Tone.now() + time + eighth + 0.01);
// after
snare.triggerAttackRelease("8n", time + eighth + 0.01);The fix is committed and ships with the next deploy; as of 2026-10-10 the live bundle still has the old call.
Why Is My AudioContext Suspended, and Does It Break Timing?
An AudioContext created before a user gesture starts suspended in Chrome, and currentTime does not advance while it is suspended. A scheduler reading that frozen clock sees no time pass and queues nothing, which looks like a scheduling bug and is a permission state.
Chrome's autoplay policy covers the Web Audio API since Chrome 71. The W3C draft says that when a context is not allowed to start, the resume() promise stays pending and does not reject. Chromium 151 behaved that way, with and without --autoplay-policy=document-user-activation-required:
- On load, no gesture: contexts started
suspended, andresume()was still pending after 1,500 ms withcurrentTimeat 0. resume()in a click handler: resolved, withstatechangetorunning8 ms after the click.start()on a node of a never-resumed context, after a click: the context wentrunning2.5 ms later, matching Chrome's note that it "will be resumed after a user gesture if start() is called on any attached node."
My first probe reported every context running, because Playwright's page.evaluate() itself grants user activation. I moved the probes to page load and read state through CDP Runtime.evaluate with userGesture: false. The wiring is short:
const ctx = new AudioContext(); // 'suspended' until the page has user activation
startButton.addEventListener('click', async () => {
await ctx.resume(); // resolves inside a user gesture
startMetronome(); // currentTime is advancing now
});Psychedelica's first sound starts inside its Proceed click, and nothing in src/ calls resume() or Tone.start(). It works because Transport.start() resumes the context. AudioViz's microphone visualizer creates its context inside the getUserMedia promise and started running with no click. I found no documentation for that exemption: observed in Chromium 151, not a rule. The route-level version, a Continue button, exists because starting audio needs a user gesture the browser will honour.
How Much Does the Drift Drop With a Lookahead Scheduler?
In headless Chromium 151.0.7922.34 on a server with a fake audio device, with a 125 ms grid and 3 runs of 160 notes per cell, the lookahead scheduler held every note within 0.01 ms of the grid when idle or busy. Start-now schedulers landed up to 2,798 ms off under stalls. Figures are grid error: mean, then worst.
- Idle,
setInterval, start now: 94.2 ms, 316.8 ms. The error comes from the clock disagreement above, not from load. - Idle, chained
setTimeout, start now: 40.0 ms, 121.2 ms. - Idle, lookahead, either tick: 0.01 ms, 0.01 ms; 0 of 480 notes late.
- Busy,
setInterval: 83.8 ms, 297.7 ms. - Busy, chained
setTimeout: 852.7 ms, 1,923.5 ms, drifting +4.4 to +5.3 s per minute. - Busy, lookahead, either tick: 0.01 ms, 0.01 ms; 0 of 480 late.
- Stall,
setInterval: 742.8 ms, 1,772.2 ms. - Stall, chained
setTimeout: 1,444.3 ms, 2,798.1 ms. - Stall, lookahead on a
setTimeouttick: 7.3 ms, 184.8 ms; 46 of 480 late. - Stall, lookahead on a worker tick: 7.8 ms, 205.1 ms; 45 of 480 late.
The 0.01 ms figure is the floor of the method: at 44.1 kHz, 125 ms is 5,512.5 frames, so a start can only miss by half a sample, 0.011 ms. Every lookahead note in the idle and busy cells started on its exact frame, 1,920 of 1,920.
Where Does Web Audio Scheduling Still Break?
Web Audio scheduling breaks when a stall outlasts the lookahead and when a silent tab is throttled. I reproduced both in headless Chromium 151. Output latency on real devices, Safari and iOS I did not test.
A Stall Longer Than the Lookahead
With 150 to 300 ms stalls and a 100 ms lookahead, 91 of 960 notes were queued after their time had passed, up to 185 ms late with the setTimeout tick and 205 ms with the worker tick, and played as soon as possible. The worker did not help: it kept ticking, but the onmessage handler that schedules notes runs on the blocked main thread. I tested 100 ms only.
Hidden Tabs
A silent hidden tab throttles the timer that feeds the scheduler, and a worker tick avoids it. Playwright could not hide a page (document.visibilityState stayed visible), so I drove the same Chromium binary over raw CDP with a second tab.
- Hidden about a minute, silent: the
setIntervalmetronome fired every 1,000 ms and issued 55 notes where 8 a second was expected. Lookahead on asetTimeouttick queued 407 of 448 notes after their time (median 444 ms late). Lookahead on a worker tick had 0 late notes. - Hidden 6.5 minutes, silent: from about 65 s,
setTimeoutcallbacks came once every 60 s, and 2,763 of 2,820 lookahead notes were late (median 23.7 s, max 58.7 s). The worker-ticked scheduler queued 3,033 notes, none late. - Hidden but audible: the
setIntervalmetronome kept a 125 ms mean gap, consistent with the exemption for pages that have "made noises in the past 30 seconds" in Chrome's 2021 throttling post.
That Chrome 88 post says once-a-minute throttling needs a page hidden for more than 5 minutes. The 65 s onset is headless Chromium 151 only; I did not check headed desktop Chrome and found no current Chrome source for the timing.
Limitations
- All figures come from Chromium 151.0.7922.34 under Playwright 1.62.1 on a fake audio device. Its latencies, the 10 s clock probe and the 8.7 or 11.6 ms callback steps are that device's. Bluetooth latency, which MDN says varies with platform and hardware, was not measured.
- Safari, iOS and Firefox were not run. MDN notes Firefox rounds
currentTimewithprivacy.reduceTimerPrecision. - An
AudioWorkletruns on the rendering thread, which the spec separates from the control thread. I used one only to measure onsets and make no claim about a scheduler inside one. - Psychedelica's story mode times twelve scene changes with
setTimeoutand the music with Tone's clock. I have not seen the narration drift, and I did not test it in a hidden tab. It sometimes crashes or refuses to load because its audio files are large, which these measurements do not cover.
The permission and device-state half of Web MIDI is the sibling to this piece, and more Browser Creative field notes are in the category.
A lookahead scheduler does not put the main thread on time. It makes the main thread's lateness stop mattering, until a stall outlasts the lookahead.
When a tab is hidden and silent, or running on iOS Safari, how do you keep the scheduler ticking: a worker, a longer lookahead, or something else?
References
- MDN: currentTime, resume(), getOutputTimestamp(), outputLatency, start().
- W3C Web Audio API 1.1, Working Draft 22 September 2026:
currentTime, "allowed to start",resume()and the control and rendering threads. - Chrome for Developers: Autoplay policy in Chrome and Heavy throttling of chained JS timers beginning in Chrome 88 (last updated 2021-01-18).
- web.dev: A Tale of Two Clocks (Chris Wilson, last updated 2013-01-09).
- Tone.js 15.0.4 source, AudioViz and Psychedelica source, and the demo pages and captures recorded on 2026-10-10: the primary source for every measured figure above.