ALL POSTS
EngineeringMay 15, 20264 min read

Chrome Web Audio: resume() before fetch/decode

If your unlock handler awaits fetch or decodeAudioData before await context.resume(), Chrome can reject resume() even right after a tap. Fix the await order; scroll is not a gesture.

TL;DR

  • Wrong: first await in the tap handler is fetch / decodeAudioData, then context.resume().
  • Right: create AudioContext sync (no await), then first await = resume(), then load/decode, then play.
  • Scroll-driven UI sound still needs a real gesture path (pointerdown, click, keys, touch). Do not use wheel/scroll as your unlock.

Context: scroll-linked typewriter ticks on this site (nickouv.com), MP3s decoded once into AudioBuffers. Same bug pattern applies to any UI sound that lazy-loads samples inside the gesture handler.

Symptom

Console on first tap:

The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page.

Stack often points at resume(). You did call resume() from code triggered by the tap. It fails because by then the gesture chain no longer counts: something async ran first and broke the association Chrome expects.

Why it happens

performUnlock was effectively:

  1. await network + decodeAudioData for every sample (slow on cold cache).
  2. await context.resume().
  3. Play audio.

Step 1 returns control to the browser. When step 2 runs milliseconds later, Chrome treats resume() as not tied to the original activation. Policy details: Chrome autoplay — Web Audio, Web Audio autoplay.

Correct unlock shape

typescript
1private async performUnlock(): Promise<boolean> { 2 const context = this.getOrCreateContext(); // sync: new AudioContext + gain only 3 if (!context) return false; 4 5 // First await in this chain MUST be resume when suspended 6 if (context.state === "suspended") { 7 await context.resume(); 8 } 9 10 await this.loadBuffers(); // fetch + decodeAudioData here 11 12 // optional: tiny BufferSource warmup, then set unlocked + notify listeners 13 return true; 14}

preload() may run earlier (decode without resume()). Only the gesture-gated path needs this ordering.

Scroll + React

  • Drive ticks from scroll only after unlocked; gate unlock({ fromUserGesture: true }) so scroll handlers cannot call it blindly.
  • If words advance before unlock finishes, subscribe to “unlocked” and re-run playback logic so early reveals are not silent forever.

Implementation: src/lib/typewriter-sound.ts, src/hooks/use-scroll-typewriter-sound.ts in this repo.

Rules

Do

  • Create AudioContext synchronously on the gesture path (no await before it).
  • Make the first await in that handler await context.resume() when state === "suspended".
  • Run fetch / decodeAudioData after resume().
  • Optionally preload buffers without calling resume() until gesture.
  • Subscribe / replay playback when unlock completes if UI moved ahead.

Don't

  • await network or decode before resume() inside the same gesture handler.
  • Rely on wheel / scroll alone to unlock Web Audio.
  • Drop sounds that were "requested" while buffers were still loading unless you replay after unlock.

References