Sound playback on the web
Unlock the AudioContext on the first gesture, decode once, one source node per play, a persisted mute that is off by default, and never information carried by sound alone.
Raw markdown On GitHubreferences/sound/sound-playback-web.md
Taught in
Browsers block audio until the user has interacted with the page, and they are right to. Build playback around that fact instead of fighting it: create the context lazily, resume it inside the first real gesture, preload and decode every sound before it is needed, and play each one from a fresh source node so latency is effectively zero. Sixty lines, no library — or use-sound if you want the hook.
The pattern
// sounds.ts — one context, decoded buffers, zero-latency play.
const files = { tick: "/sfx/tick.mp3", send: "/sfx/send.mp3", error: "/sfx/error.mp3" };
type Name = keyof typeof files;
let ctx: AudioContext | null = null;
const buffers = new Map<Name, AudioBuffer>();
let enabled = localStorage.getItem("sound") === "on"; // off by default
async function unlock() {
ctx ??= new AudioContext();
if (ctx.state === "suspended") await ctx.resume(); // must run inside a user gesture
await Promise.all(Object.entries(files).map(async ([name, url]) => {
if (buffers.has(name as Name)) return;
const data = await fetch(url).then((r) => r.arrayBuffer());
buffers.set(name as Name, await ctx!.decodeAudioData(data));
}));
}
// Unlock on the first pointer or key event, once, before any sound is wanted.
addEventListener("pointerdown", unlock, { once: true });
addEventListener("keydown", unlock, { once: true });
export function play(name: Name, { rate = 1, gain = 1 } = {}) {
if (!enabled || !ctx || ctx.state !== "running") return;
const buf = buffers.get(name); if (!buf) return;
const src = ctx.createBufferSource(); src.buffer = buf; src.playbackRate.value = rate;
const g = ctx.createGain(); g.gain.value = gain;
src.connect(g).connect(ctx.destination); src.start(); // new node per play — they are cheap
}
export function setSound(on: boolean) {
enabled = on; localStorage.setItem("sound", on ? "on" : "off");
if (on) play("tick"); // confirm the toggle with itself
}AudioBufferSourceNode is the only web primitive with no start-up latency; <audio> elements and HTMLMediaElement.play() add tens of milliseconds and fail the sync rule in sound-motion-sync. If you prefer a hook, use-sound (≈1 KB over Howler) exposes volume, playbackRate, interrupt, soundEnabled, and sprite — the same shape as above.
The five rules
- Off by default, with a toggle the user can find. A visible control in settings and reachable by keyboard; state persisted across sessions. Josh Comeau's rule, and the reason sound on the web is trusted at all.
- No sound-only information. Every sound has a visual twin — the state change, the toast, the shake. Muted users, screen-reader users, and everyone in an open office are the majority.
- No autoplay, no sound on page load. Even after unlock, a sound the user did not cause is a notification, and notifications need permission.
- Respect the platform's silence. On iOS the Ring/Silent switch mutes "nonessential" audio — keyboard clicks, effects, feedback — and your sounds are nonessential. Native: use the
ambientaudio session category so you mix with, and yield to, the user's music. Web: don't fightdocument.hidden; a hidden tab's UI sounds should not play. - Treat the mute toggle as the
prefers-reduced-motionof audio. CSS has no audio media query. The closest signals are the user's toggle, OS focus modes on native, and — as a weak hint —prefers-reduced-motion: reduce, which correlates with users who want less stimulus. When it is set, default the toggle off and skip the onboarding "turn on sound?" prompt.
Screen readers
Sounds fired on focus or on every list item collide with the reader's own speech. Never attach sound to focus, focusin, or roving tab-index movement. Confirmation sounds on explicit actions (send, delete) are fine, and should be shorter than the reader's announcement of the same event.
When to apply
Any PR that adds an audio file or an AudioContext. Check for the unlock, the toggle, the visual twin, and the absence of focus handlers — review-checklist rows 12 and 13.
Gotcha
Calling new AudioContext() at module load "works" in Chrome — it just starts suspended and every play() silently drops until something resumes it. The symptom is "sound works on the second click." Create and resume inside the gesture, or resume in the handler before playing.
Sources
- Chrome for Developers, Autoplay policy and Web Audio, autoplay policy and games — suspended contexts,
resume()after user activation. - MDN, Web Audio API —
decodeAudioData,AudioBufferSourceNodesemantics. - Josh Comeau, use-sound — mute toggle, persistence, never sound-only, the hook API.
- Apple Human Interface Guidelines, Playing audio — silent switch, audio session categories; Feedback — provide feedback through color, text, sound, and haptics so people can receive it in the way that works for them.
- Related: accessibility-baseline, prefers-reduced-motion.
soundweb-audioplaybackaccessibilityaudio