Web Audio unlock for HTML5 games: autoplay, ads and hidden tabs
Why browsers start your AudioContext suspended, how to resume it inside a user gesture, and how to mute for portal ads and pause audio when the tab is hidden.
Browsers hold back audio until the player interacts with the page. A game that creates its AudioContext at load and never resumes it stays silent. This guide explains the rules as documented by Chrome, MDN and WebKit, then gives a small helper that handles the unlock, portal ad breaks and hidden tabs.
What the browsers document
Chrome. Chrome’s autoplay policy (last updated 2017-09-13) has covered the Web Audio API since Chrome 71. Sound is allowed after the user has interacted with the domain, when the desktop Media Engagement Index threshold is crossed, or when the site is installed. A top frame can delegate autoplay to an iframe with allow="autoplay". For Web Audio specifically, a context created before any user gesture starts in the "suspended" state. You call resume() after the gesture, or the context resumes when a node’s start() is called after one.
MDN. The autoplay guide gives cross-browser rules of thumb. Audible playback is likely allowed only if the user has interacted with the site, the site is allowlisted, or an autoplay Permissions Policy grants it to the iframe. Starting a Web Audio source outside an input handler is subject to these rules. The exact conditions vary by browser. MDN’s Web Audio best practices boil it down to one rule: create or resume the context inside a user gesture.
WebKit. Both WebKit posts in this guide’s sources are about media elements, and they are old. The 2016 iOS post defines “as a result of a user gesture” strictly: the play() call must come directly from a touchend, click, doubleclick or keydown handler, not from a later event such as canplaythrough. The 2017 macOS post says to assume media needs a click to play, and to check the promise returned by play(). Neither post covers AudioContext directly. Calling resume() synchronously inside the input handler satisfies the strict reading, so do that.
Which events count as a gesture
MDN’s user activation page lists the trusted events that activate a page: keydown (not Escape or some reserved keys), mousedown, pointerdown for mouse, pointerup for other pointer types, and touchend. A touch pointerdown does not count, so a game that unlocks audio only on pointerdown can stay silent on phones. Listen for several events.
The same page separates two states:
- Transient activation: a short window after an interaction, consumed by some APIs.
- Sticky activation: set once the user has interacted and kept for the session. MDN lists autoplay of media and Web Audio (in particular for AudioContexts) as gated on sticky activation.
navigator.userActivation exposes both as isActive and hasBeenActive. MDN’s compatibility data lists it from Chrome 72, Firefox 120 and Safari 16.4. By contrast, navigator.getAutoplayPolicy() is experimental and listed only for Firefox (112+) in that data. Do not build your unlock around it.
AudioContext states
BaseAudioContext.state can be:
"running": audio is playing normally."suspended": paused bysuspend()or, in practice, by the autoplay block at creation."interrupted": paused by something outside the app, such as a call taking the audio device. The browser decides when it resumes."closed": afterclose().resume()rejects only in this state.
Listen for statechange rather than polling. Any state other than running means “wait for the next gesture and try again”.
A small unlock helper
This helper uses one context for the whole game and routes every sound through a master gain node. It arms gesture listeners whenever the context is not running, mutes synchronously for ads, and suspends while the page is hidden.
// game-audio.js: one AudioContext for the whole game.
// Unlocks on the first user gesture, mutes for ads, pauses while hidden.
const GESTURES = ['pointerdown', 'pointerup', 'touchend', 'mousedown', 'keydown', 'click'];
export function createGameAudio() {
const ctx = new AudioContext();
const output = ctx.createGain(); // connect every sound to this node
output.connect(ctx.destination);
const muteReasons = new Set(); // e.g. 'ad', 'settings'
let armed = false;
function tryResume() {
if (ctx.state !== 'running' && ctx.state !== 'closed' && !document.hidden) {
ctx.resume().catch(() => {}); // rejected only if the context is closed
}
}
function arm() {
if (armed) return;
armed = true;
for (const type of GESTURES) {
window.addEventListener(type, tryResume, { capture: true, passive: true });
}
}
function disarm() {
if (!armed) return;
armed = false;
for (const type of GESTURES) {
window.removeEventListener(type, tryResume, { capture: true });
}
}
// Whenever the context stops running (autoplay block, suspend, interruption),
// wait for the next gesture to resume it.
ctx.addEventListener('statechange', () => {
if (ctx.state === 'running') disarm();
else if (ctx.state !== 'closed') arm();
});
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
ctx.suspend().catch(() => {});
} else {
tryResume(); // may still need a gesture; the listeners cover that
}
});
if (ctx.state !== 'running') {
arm();
// Sticky activation may already exist (e.g. the player clicked a menu first).
if (navigator.userActivation?.hasBeenActive) tryResume();
}
return {
ctx,
output,
get unlocked() {
return ctx.state === 'running';
},
setMuted(reason, muted) {
if (muted) muteReasons.add(reason);
else muteReasons.delete(reason);
output.gain.value = muteReasons.size > 0 ? 0 : 1; // synchronous
},
};
}
export async function loadSound(audio, url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
return audio.ctx.decodeAudioData(await res.arrayBuffer());
}
export function playSound(audio, buffer, { loop = false } = {}) {
const source = audio.ctx.createBufferSource();
source.buffer = buffer;
source.loop = loop;
source.connect(audio.output);
source.start();
return source;
}
Usage:
import { createGameAudio, loadSound, playSound } from './game-audio.js';
const audio = createGameAudio();
const jump = await loadSound(audio, 'sfx/jump.mp3');
startButton.addEventListener('click', () => {
playSound(audio, jump); // the capture-phase listener has already called resume()
});
Why it is built this way:
- Capture-phase listeners on
windowrun before your game’s own handlers, soresume()is called inside the gesture even if a handler callsstopPropagation(). - Several event types cover both the MDN activation list and the WebKit gesture list, including
touchendandpointerupfor touch. - Re-arming on
statechangehandles a context that gets suspended or interrupted later, not only the first block. - A mute set keeps ad muting and the player’s own mute setting separate, so an ad ending does not unmute a player who muted the game.
Show a visible “tap to start” or sound toggle before gameplay. Chrome’s page recommends that pattern, and so does the Godot web export page for Godot games.
Mute during ads
The three portal SDKs covered on this site ask for the same thing, with different callback names. See this site’s integration guides for full code:
- Google Ad Placement API. In
adBreak(),beforeAdmust pause and mute synchronously, because the ad shows as soon as the callback returns.afterAdresumes. See the Ad Placement API guide. - CrazyGames SDK. The video ads docs say to mute and pause in
adStarted, and to restore both inadFinishedandadError. See the CrazyGames SDK guide. - Poki SDK. The HTML5 SDK guide says to disable audio and keyboard input during commercial breaks. Its comments warn that the pause callback may not always be called, so unmute after the promise resolves either way. See the Poki SDK guide.
With the helper, all three look the same:
// Google Ad Placement API example
adBreak({
type: 'next',
name: 'level-complete',
beforeAd: () => { game.pause(); audio.setMuted('ad', true); },
afterAd: () => { audio.setMuted('ad', false); game.resume(); },
});
Use the gain node, not ctx.suspend(), for ads. suspend() returns a promise, and Google’s docs require the mute to happen synchronously.
Pause when the page is hidden
The visibilitychange event fires when the player switches tabs, minimizes the browser or, on mobile, switches apps. MDN calls the transition to hidden the right moment to stop work the user does not want running in the background. The Page Visibility API page adds two facts that matter for games. Most browsers stop requestAnimationFrame callbacks in background tabs and hidden iframes. Tabs playing audio count as foreground and are not throttled. So an unpaused game can keep playing music in a background tab while its game loop has stopped. The helper suspends the context when hidden. Pause your game state in the same handler.
Engines
- Phaser 3.90. The WebAudioSoundManager has a
lockedflag and anunlock()method, documented as unlocking Web Audio on the initial input event, and fires anUNLOCKEDevent. The BaseSoundManager provides a globalmuteand apauseOnBlurflag. Usethis.sound.mutefor ad breaks instead of a second context. - Godot 4. The web export page recommends asking the player to click or press a key before audio, for example on a splash screen. See the Godot 4 web export guide.
What to check before you submit
- Load the game in a fresh private window and do not touch it. No errors should appear, and sound should start on the first tap, click or key press.
- On a phone, the first touch unlocks audio. Test a tap that fires only
touchend/pointerup, not a click. - Music and effects stop when you switch tabs or apps, and come back when you return.
- During every ad type (interstitial and rewarded), the game is silent and paused. It is still silent when the ad fails to load.
- A player who muted the game stays muted after an ad.
- Only one
AudioContextexists. Search the code fornew AudioContextand check the engine is not creating its own as well. - The console shows no autoplay warnings after the first interaction.
Limits of this guide
Chrome’s autoplay page was last updated in 2017 and both WebKit posts are from 2016-2017. They describe media elements more than AudioContext, and neither vendor has published an updated Web Audio policy among the sources checked here. Browser behaviour may have moved since. The helper has not been verified on physical iOS or Android devices for this guide, so test the first-tap unlock there before you ship.
Questions
- Why is my AudioContext state 'suspended' when the game loads?
- Browsers apply autoplay rules to Web Audio. Chrome's documentation says a context created before a user gesture starts suspended and must be resumed with resume() after the player interacts.
- Should I create the AudioContext on load or on the first click?
- Either works. Creating it on load lets you decode sounds early; you then call resume() from the first gesture handler. MDN's best-practices page summarises the rule as creating or resuming the context inside a user gesture.
- Do I need to mute my game during portal ads?
- Yes. Poki, CrazyGames and Google's Ad Placement API documentation all tell you to mute audio (and pause the game) when an ad starts and restore it when the ad ends.
Sources
- Autoplay policy in Chrome (Chrome for Developers, last updated 2017-09-13)
- Autoplay guide for media and Web Audio APIs (MDN)
- New video policies for iOS (WebKit blog, 2016-07-25)
- Auto-play policy changes for macOS (WebKit blog, 2017-06-08)
- Web Audio API best practices (MDN)
- BaseAudioContext: state property (MDN)
- AudioContext: resume() method (MDN)
- EventTarget: addEventListener(), capture option (MDN)
- User activation (MDN)
- Navigator: userActivation property (MDN)
- Navigator: getAutoplayPolicy() method (MDN)
- Navigator compatibility data (mdn/browser-compat-data on GitHub)
- Page Visibility API (MDN)
- Document: visibilitychange event (MDN)
- adBreak() API reference (Google Ad Placement API)
- Video ads (CrazyGames SDK docs)
- HTML5 SDK: commercial breaks and disabling sound during ads (Poki for Developers)
- WebAudioSoundManager, Phaser 3.90.0 API (Phaser docs)
- BaseSoundManager, Phaser 3.90.0 API (Phaser docs)
- Exporting for the Web: audio (Godot Engine documentation, stable)