Someone left a thumbs-down on one of my tools with a two-word message: "No sound". No email, no device, nothing else. I opened the page on three browsers and a phone, and it screamed at me every time.
That report was correct, and I could not reproduce it, because the thing that breaks is a physical switch on the side of a phone. If your site synthesizes audio in the browser, some percentage of your visitors are getting silence right now and none of them are telling you.
Here are the four behaviours involved. Individually they're all findable; I have never seen them written down together, and two of them contradict what the top search results still recommend.
1. The ringer switch mutes Web Audio, but not <audio>
An iOS page's audio session defaults to the type ambient, and ambient is exactly the category the ringer switch governs. Flick the phone to silent and:
- an
<audio>or<video>element keeps playing - everything through the Web Audio API goes silent
No error. No console warning. AudioContext.state still reads running, oscillators still start and stop on schedule, and every promise resolves. The API cheerfully reports success while producing nothing.
This is why it hides so well. Sites that ship audio files barely notice, because the element path is unaffected. Sites that synthesize audio — game blips, timer alarms, anything built from OscillatorNode — are 100% exposed. My tools ship no audio assets at all, by rule, so all 23 pages that make noise were silent on a silenced iPhone.
The worst of it wasn't the tool that got the complaint. It was the classroom timers: a teacher on a muted iPad, waiting for an alarm at zero that was never going to arrive. Nobody reported that for months, because timers get driven on desktops.
2. The bug everyone links to is closed, and the fix is a one-liner
Search this problem and you'll land on WebKit bug 237322, "webaudio api is muted when the iOS ringer is muted". Most write-ups treat it as the open, unfixable state of the world and hand you a workaround.
It is RESOLVED / CONFIGURATION CHANGED. The resolution comment tells you what to do instead:
navigator.audioSession.type = 'playback';
That's it. ambient is what the switch mutes; playback is not. MDN's compat data puts navigator.audioSession in Safari 16.4 (the WebKit comment on the bug says iOS 17 — either way it's years old now, and it's feature-detectable).
The workaround it replaced is the one still being recommended everywhere: play a tiny silent <audio> element inside a user gesture to drag the page off the ambient category. It's a real technique, and worth keeping as a fallback for old Safari — but note what the good implementations of it actually do. swevans/unmute plays the silent track continuously while Web Audio is playing; feross/unmute-ios-audio re-fires on every interaction. A one-shot 1 ms clip, which is what you'd naively write, lets the category revert as soon as it ends.
I shipped the one-shot version first, in a plan I'd written after a couple of hours of reading, describing the AudioSession API as the "eventual replacement". It was already the current one. If a workaround is your whole fix, check whether the bug it works around is still open.
3. playback is incompatible with getUserMedia — and the ordering is a trap
This is the one that would have shipped a real regression.
playback is an output-only session type. While a page holds it, WebKit rejects microphone capture outright — the check is in MediaDevices.cpp and the promise rejects with InvalidStateError, "AudioSession category is not compatible with audio capture". The Audio Session spec goes further for a capture that's already live:
If
audioSession.[[type]]is notplay-and-recordorauto, end track.
So on any page that both plays sound and listens — a classroom noise meter that beeps when the room gets loud, say — claiming playback doesn't degrade the microphone. It removes it.
And here's the trap. The obvious place to hand the session back is where you set up the audio graph for the stream:
// WRONG — the request has already been rejected by the time this runs
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
releaseAudioSession();
const source = ctx.createMediaStreamSource(stream);
That's too late. The rejection happens inside getUserMedia, so a release after the await never executes on the path that needs it. It has to go first:
// RIGHT — before the request, always
releaseAudioSession();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
What makes this expensive to debug is the shape of the failure. The promise rejects, your existing catch sets micState = 'denied', and the user sees "microphone denied" for a permission they just granted. Nothing in that error mentions audio sessions.
I wrote the wrong version. It took an adversarial review pass reading WebKit's source to catch it, and it would have broken two live tools that had nothing to do with the change.
4. AudioContextState has a fourth value nobody checks
Everyone writes this:
if (ctx.state === 'suspended') await ctx.resume();
Open your own lib.dom.d.ts:
type AudioContextState = "closed" | "interrupted" | "running" | "suspended";
interrupted is WebKit's non-standard fourth state, and it's what iOS actually reports for a tab switch, a screen lock, or an incoming call. Checking === 'suspended' sails straight past it.
The consequence is worse than a missed resume, because a frozen context still accepts scheduling. Read ctx.currentTime on an interrupted context and you get a timestamp that isn't advancing; every tone you queue against it sits there, and they all fire at once whenever the clock finally restarts. A ten-second countdown interrupted at the wrong moment doesn't lose its ticks — it saves them up and plays them as a single chord on return.
Check !== 'running' instead, and exclude closed from anything you'd call resume() on (that one rejects, and it's the only state resume() can't leave):
function resumeIfFrozen(ctx: AudioContext): Promise<void> | null {
const { state } = ctx;
return state === 'running' || state === 'closed' ? null : ctx.resume();
}
The cost nobody mentions: playback is exclusive
The spec is blunt about what you're buying:
They should not mix with other playback audio. (Maybe) they should pause all other audio indefinitely.
WebKit maps playback to AVAudioSessionCategoryPlayback, which interrupts other non-mixable sessions and does not resume them. So the moment your page makes a sound, the user's music or podcast stops. For good.
For a two-second alarm that's rude. For anything long-running it's disqualifying — and my worst case was a pomodoro timer with looping rain sounds, held for a 25-minute focus block. A focus timer that kills your music for 25 minutes is not a focus timer.
The tempting escape is transient-solo, which is spec'd to resume the other audio afterwards. It doesn't help: it maps to SoloAmbient, which the ringer switch still silences. You're back where you started.
There's no clever answer. It's a per-tool judgement:
- The sound is the point — an alarm, a scream, a game's music — claim
playbackand accept interrupting. - The sound is background — ambience under someone else's audio — stay mixable and accept being silent-switch-muted.
I ended up with the claim everywhere except the pomodoro timer, which opts out while its soundscape is on. With the soundscape off it opts back in, because then the only sound left is a phase chime — an alarm like any other.
The general shape
Strip out the iOS specifics and this is a pattern worth recognising anywhere:
When a fix is a document-level mode switch rather than a per-call flag, it is not a local decision — and something else on the page probably forbids it. Scope it to a lifecycle, refcount it, and make the incompatible path release it before the operation that would fail, not after.
Both of my mistakes were the same mistake in different clothes. A page-lifetime flag leaks the wrong mode into the next client-side route. A release after the await runs on every path except the broken one.
// Refcounted, because two things on one page can each need the exemption,
// and the page must get the fix back when the last of them is done.
let liveCaptures = 0;
export function beginAudioCapture(): void {
liveCaptures += 1;
setSessionType('auto');
}
export function endAudioCapture(): void {
liveCaptures = Math.max(0, liveCaptures - 1);
}
What I still don't know
The honest limits, because they're the part that ages best:
- I never confirmed the person who reported it was on an iPhone. The fix is justified as a documented platform defect on its own merits. It is not proof I fixed that report, and I've been careful not to write it up as one.
- None of this is verifiable in CI or a simulator. It's a hardware switch. My whole test suite — 3,195 tests, green — proves the logic and proves nothing about the platform. The only real verification is a physical phone with the switch flicked.
- I don't know whether the legacy one-shot path works at all. It's gated to Safari versions I don't have, so it's documented in the code as "may work", not "covered". If a report ever comes back from an old device, the fix is to make it continuous.
If you build anything that makes noise in a browser, the five-second version: put an iPhone on silent, open your site, and press the button that should make sound. That's the whole test. It's the one I'd never run.
The tools this came out of are all free and client-side, if you want to poke at them: the jumpscare prank that got the complaint, the tabata timer that was quietly broken for months, and the classroom noise meter that nearly lost its microphone to the fix.