← DEVLOG
Tech Notes2025.09.126 min read

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.

iossafariaudioweb-audiomobile

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:

  1. onClick callback runs; setState is called
  2. React batches the state update
  3. Event loop advances to the next tick, then re-render happens
  4. useEffect runs at that point
  5. By now, the user gesture context is already gone
  6. 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:

  1. In SSR, AudioContext is not defined error
  2. 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.state actually '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 webkitAudioContext fallback?
  • Does visibilitychange retry 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

0 / 140

Loading...