Why iOS Safari Silently Refuses Your AudioContext
Everything worked everywhere except iPhone. Why iOS only accepts resume() from directly inside the gesture callback, and the dummy-unlock trick that fixes it.
Opening — "Why No Sound Only on iPhone?"
After adding Web Audio–synthesized BGM and SFX, macOS Safari / Chrome / Firefox / Android Chrome all worked fine. Only iPhone Safari played silence after the first tap started audio.
AudioContext.state showed 'suspended'. Calling ctx.resume() did nothing — still suspended. Finding the cause took a while.
The iOS Rule — resume() Only Succeeds "Directly Inside" the Gesture Callback
iOS Safari strictly blocks autoplay. For AudioContext to reach running state, resume() must be called directly inside a user gesture callback.
The catch: "directly inside" is a narrower definition than it sounds. Consider this React code:
onClick={() => {
setGameState('playing'); // React state update
// want to start audio here
}}
// in another component's useEffect:
useEffect(() => {
if (gameState === 'playing') {
startBGM(); // ← ctx.resume() called here
}
}, [gameState]);
On iOS, this does not work. Because:
onClickcallback runs;setStateis called- React batches the state update
- Event loop advances to the next tick, then re-render happens
useEffectruns at that point- By now, the user gesture context is already gone
- iOS: "this is autoplay" → blocked
macOS Safari and Chrome are lenient, so it works there. iOS is strict.
The Fix — Dummy AudioContext Unlock
Core idea: create a throwaway AudioContext at the very top of onClick and resume it immediately. That flips iOS's "user-gesture counter", letting subsequent AudioContexts resume normally.
onClick={(e) => {
// iOS Safari unlock — at the top of onClick, synchronously
try {
const AC = window.AudioContext || (window as any).webkitAudioContext;
if (AC) {
const u = new AC();
void u.resume().finally(() => u.close());
}
} catch {}
// then the usual logic
setGameState('playing');
}}
Properties of this code:
- Synchronous: no awaiting in a promise chain
- Close immediately: the dummy ctx isn't used; close it right after resume
- try/catch: handle older browsers
Once this dummy unlock succeeds, every subsequent AudioContext in the tab (even ones created inside useEffect) can resume freely.
Why It Works
iOS's judgment criterion is essentially: has any AudioContext successfully resumed inside a user gesture in this tab?
- Never succeeded → every new ctx stays suspended
- Has succeeded → all subsequent ctx are allowed
Resume on the dummy ctx flips that "has succeeded" flag. Actual audio usage can happen later.
Other Traps
Never new AudioContext() at Module Level
// ❌ Never
const _ctx = new AudioContext();
Two reasons:
- In SSR,
AudioContext is not definederror - On iOS, created outside a gesture → permanently suspended
Fix: always create inside a user event handler, lazily.
AudioContext vs webkitAudioContext
Older iOS Safari only exposed webkitAudioContext. Modern versions accept AudioContext too, but keep the fallback for legacy support:
const AC = window.AudioContext || (window as any).webkitAudioContext;
Resume on visibilitychange
iOS Safari sometimes re-suspends the ctx when the tab comes back from background. Resume again on visibilitychange:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && _ctx?.state === 'suspended') {
void _ctx.resume();
}
});
Already unlocked, so automatic resume works here.
Debugging Checklist
If no sound on iOS, check in order:
- Is
AudioContext.stateactually'running'? (needs DevTools remote debugging) - Is the dummy unlock the first line of
onClick? - Is AudioContext created inside a user event — not at module level?
- Is there a
webkitAudioContextfallback? - Does
visibilitychangeretry resume on tab return?
Retrospective
I found this problem only after everything passed elsewhere — testing on an actual iPhone. iOS Simulator doesn't always reproduce it, so a real device was mandatory.
Most developers know "autoplay is blocked", but "React's batched flush severs the gesture context" gets overlooked often. That's why code running fine on other OSes fails silently on iOS.
If your project touches mobile audio, add iPhone device testing to the checklist.
Guestbook
Leave a short note about this post
Loading...