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.

Published
Sources checked
Versions
Phaser 3.90.0

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 by suspend() 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": after close(). 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 window run before your game’s own handlers, so resume() is called inside the gesture even if a handler calls stopPropagation().
  • Several event types cover both the MDN activation list and the WebKit gesture list, including touchend and pointerup for touch.
  • Re-arming on statechange handles 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(), beforeAd must pause and mute synchronously, because the ad shows as soon as the callback returns. afterAd resumes. See the Ad Placement API guide.
  • CrazyGames SDK. The video ads docs say to mute and pause in adStarted, and to restore both in adFinished and adError. 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 locked flag and an unlock() method, documented as unlocking Web Audio on the initial input event, and fires an UNLOCKED event. The BaseSoundManager provides a global mute and a pauseOnBlur flag. Use this.sound.mute for 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 AudioContext exists. Search the code for new AudioContext and 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

  1. Autoplay policy in Chrome (Chrome for Developers, last updated 2017-09-13)developer.chrome.com, accessed
  2. Autoplay guide for media and Web Audio APIs (MDN)developer.mozilla.org, accessed
  3. New video policies for iOS (WebKit blog, 2016-07-25)webkit.org, accessed
  4. Auto-play policy changes for macOS (WebKit blog, 2017-06-08)webkit.org, accessed
  5. Web Audio API best practices (MDN)developer.mozilla.org, accessed
  6. BaseAudioContext: state property (MDN)developer.mozilla.org, accessed
  7. AudioContext: resume() method (MDN)developer.mozilla.org, accessed
  8. EventTarget: addEventListener(), capture option (MDN)developer.mozilla.org, accessed
  9. User activation (MDN)developer.mozilla.org, accessed
  10. Navigator: userActivation property (MDN)developer.mozilla.org, accessed
  11. Navigator: getAutoplayPolicy() method (MDN)developer.mozilla.org, accessed
  12. Navigator compatibility data (mdn/browser-compat-data on GitHub)github.com, accessed
  13. Page Visibility API (MDN)developer.mozilla.org, accessed
  14. Document: visibilitychange event (MDN)developer.mozilla.org, accessed
  15. adBreak() API reference (Google Ad Placement API)developers.google.com, accessed
  16. Video ads (CrazyGames SDK docs)docs.crazygames.com, accessed
  17. HTML5 SDK: commercial breaks and disabling sound during ads (Poki for Developers)developers.poki.com, accessed
  18. WebAudioSoundManager, Phaser 3.90.0 API (Phaser docs)docs.phaser.io, accessed
  19. BaseSoundManager, Phaser 3.90.0 API (Phaser docs)docs.phaser.io, accessed
  20. Exporting for the Web: audio (Godot Engine documentation, stable)docs.godotengine.org, accessed