Quick answer: Listen for visibilitychange. When the page becomes visible, call audioContext.resume(). If it’s rejected, show a tap prompt to acquire a user gesture. Recreate looping AudioBufferSourceNode instances.
Player switches from your web game to a text message, comes back 10 seconds later. The game is still on screen, gameplay continues, but the audio is dead silent. Reloading the page brings audio back. Switching apps and returning a second time silences it again.
What iOS Safari Does
When the page becomes hidden (tab switch, app switch, locked screen), Safari suspends the page’s AudioContext as a battery and policy measure. On return:
- JavaScript continues to run.
audioContext.stateis"suspended".- Calls to
source.start()appear to succeed but produce no sound.
Resume requires either a user gesture or a fresh call to audioContext.resume() in response to one.
The Fix
document.addEventListener("visibilitychange", () => {
if (document.hidden) return;
if (audioContext.state !== "suspended") return;
audioContext.resume()
.then(() => restartLoopingSounds())
.catch(() => showTapToResume());
});
function showTapToResume() {
const overlay = document.getElementById("tap-resume");
overlay.style.display = "flex";
overlay.addEventListener("pointerdown", () => {
audioContext.resume().then(() => {
overlay.style.display = "none";
restartLoopingSounds();
});
}, { once: true });
}
Two paths: resume succeeds programmatically (some iOS versions tolerate this when the gesture is recent), or you need a tap. Display an overlay with “Tap to resume” if needed.
Restarting Looping Sounds
Each AudioBufferSourceNode is single-use. After the context resumed, your previously playing music loops are dead nodes. Track loop state and recreate:
const activeLoops = new Map(); // id -> { buffer, gainNode, startedAt, when }
function playLoop(id, buffer) {
const src = audioContext.createBufferSource();
src.buffer = buffer;
src.loop = true;
src.connect(masterGain);
src.start(0);
activeLoops.set(id, { src, buffer });
}
function restartLoopingSounds() {
for (const [id, info] of activeLoops) {
playLoop(id, info.buffer);
}
}
You always know which loops should be playing. The visibility handler re-creates them all on resume. Audio resumes from the start of the loop, which is acceptable for ambient/music loops.
Preserving Playback Position
For loops where the resume should be near the previous position (e.g., long ambient track), track currentTime at suspend:
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
for (const [id, info] of activeLoops) {
info.suspendedAt = audioContext.currentTime - info.startedAt;
}
} else if (audioContext.state === "suspended") {
audioContext.resume().then(() => {
for (const [id, info] of activeLoops) {
const src = audioContext.createBufferSource();
src.buffer = info.buffer;
src.loop = true;
src.connect(masterGain);
src.start(0, info.suspendedAt % info.buffer.duration);
info.src = src;
info.startedAt = audioContext.currentTime - info.suspendedAt;
}
});
}
});
The second argument to start() is the offset within the buffer. Player hears the resumed track at the position it was when backgrounded.
Verifying
On a real iOS device, load your game, play audio, switch to another app for 5+ seconds, return. Audio should resume within ~100ms (or after a single tap if a gesture is required). Repeat 3+ times to ensure the cycle works reliably, not just once.
“iOS suspends AudioContext aggressively. Treat resume as a normal lifecycle event and your game stays loud through every tab switch.”
Always test on real iOS. Desktop Safari and emulators don’t fully reproduce the audio suspension behavior.