AudioContext Was Not Allowed to Start: How to Fix It
Chrome suspends every AudioContext until a user gesture. The fix, a resume-on-first-interaction helper, and why calling apps fail silently without it.
Chrome suspends every AudioContext until a user gesture. The fix, a resume-on-first-interaction helper, and why calling apps fail silently without it.
"The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page." is a Chrome console warning, and the object it complains about is alive and well — it is parked in the suspended state because the page has not been clicked yet. Chrome has worked this way since version 71, and the fix is one line, await ctx.resume(), as long as it runs inside a real user-gesture handler. What follows is that fix, a helper that survives real page lifecycles, the reason the bug never reproduces on your own laptop, iOS Safari's extra failure state, and why a dialer breaks harder than a music app when you get this wrong.
Chrome's autoplay policy covers the Web Audio API, and its rule is short: "If an AudioContext is created before the document receives a user gesture, it will be created in the 'suspended' state." A suspended context accepts everything you throw at it — you can build the whole node graph, connect it to ctx.destination, call osc.start() — and produce no sound at all. Its currentTime doesn't advance, so anything you schedule against the clock lands wrong once it does start.
The warning's wording carries a real inaccuracy, and it costs people hours. Creating an AudioContext outside a gesture is permitted; starting or resuming one is what's blocked. The top answer on the 37,198-view Stack Overflow thread on this warning puts it bluntly: "It's not wrong to create the context without a user gesture, but it is not allowed to start or resume the context without a user gesture." Construct at module load and resume on a click, and you may still see the warning printed once, with working audio underneath it.
Three things, in this order: resume inside a genuine user gesture, await the promise, then check state before you schedule anything.
const ctx = new AudioContext(); // legal at module load — it just starts suspended
playButton.addEventListener('click', async () => {
if (ctx.state !== 'running') {
await ctx.resume();
}
// ctx.state === 'running' from here on
const osc = ctx.createOscillator();
osc.connect(ctx.destination);
osc.start();
});
Which events qualify: click, pointerdown, pointerup, keydown, touchend — the events that grant transient user activation. mousemove, scroll, wheel, DOMContentLoaded, a setTimeout callback and a promise that resolves later do not, no matter how obviously the user caused them.
The word "transient" is the part people trip over. The activation expires, and awaiting something slow before you resume can spend it. This ordering fails intermittently:
button.addEventListener('click', async () => {
const buffer = await fetch('/ring.mp3').then((r) => r.arrayBuffer()); // ← activation may lapse here
await ctx.resume();
});
Resume first, load second. And prefer state !== 'running' over state === 'suspended', for reasons the iOS section covers.
The click that unlocks audio is often not the click that needs audio. This helper takes the first interaction of the session, whatever it is, and hands you a promise that settles once the context is genuinely running:
let pending = null;
export function unlockAudio(ctx) {
if (pending) return pending;
pending = new Promise((resolve) => {
const events = ['pointerdown', 'keydown', 'touchend'];
const attempt = async () => {
try {
await ctx.resume();
} catch {
// context was closed; nothing left to resume
}
if (ctx.state === 'running') {
events.forEach((e) => document.removeEventListener(e, attempt));
resolve(ctx);
}
};
events.forEach((e) =>
document.addEventListener(e, attempt, { passive: true })
);
attempt(); // may already be running: repeat visit, high MEI, earlier unlock
});
return pending;
}
Two details earn their keep. The listeners come off only after ctx.state reads running, because resume() can resolve on a context that stayed suspended — checking the state rather than trusting the promise is what makes this reliable on Safari. And the eager attempt() at the end covers the case where the context was never suspended to begin with, so the promise resolves immediately instead of waiting for a click that isn't coming.
Pair it with a visibilitychange listener that re-checks ctx.state whenever the tab comes back. A single unlock at startup is not durable.
You test the app, audio plays, you ship, and users report silence. That gap is Chrome's Media Engagement Index. Chrome allows autoplay with sound when any of three conditions holds: "The user has interacted with the domain (click, tap, etc.)", "On desktop, the user's Media Engagement Index threshold has been crossed, meaning the user has previously played video with sound", or "The user has added the site to their home screen on mobile or installed the PWA on desktop."
MEI is a per-origin ratio kept per browser profile, and only playbacks meeting all four of these count toward it: longer than seven seconds, audio present and unmuted, tab active during playback, and video larger than 200×140 pixels. Load your own app forty times a day and you cross the threshold; your first-time user never has. Open chrome://media-engagement to see your own scores, which is usually the moment the whole thing clicks.
For reproducing the failure deliberately — in Playwright, in CI, or just to see what a cold user sees — launch Chrome with --autoplay-policy=no-user-gesture-required to turn the policy off, or a clean profile with --disable-features=PreloadMediaEngagementData,MediaEngagementBypassAutoplayPolicies to strip the MEI shortcut and force the strict path.
Safari enforces the same gesture requirement, then adds a state Chrome doesn't use much. AudioContext.state has four documented values: running, suspended, interrupted, and closed. MDN describes the third as a context "interrupted by an occurrence outside the control of the web app," and the example it gives first is telling for anyone building phone software: "a conferencing or phone app requiring exclusive access to the device's audio hardware."
On iOS, locking the screen, taking a cellular call, or switching apps moves a running context to interrupted. Returning to the tab does not reliably restore it, and a fresh resume() from a new gesture is the only dependable recovery. As of August 2026, WebAudio/web-audio-api issue #2585 still tracks reports of iOS contexts stuck in interrupted where resume() on user input never flips the state — the practical mitigation being to recreate the context rather than keep resuming a dead one.
This is the concrete reason for state !== 'running' in every snippet above. Code that branches on state === 'suspended' walks straight past an interrupted context and reports success while producing silence.
A music player fails honestly. The user presses play, nothing happens, they press it again — and that second press is itself the gesture that fixes it. Nothing in a dialer works that way, because a dialer's loudest moment is the one the user did not initiate.
Picture a support agent with your app open in a background tab since 9 a.m., untouched. An inbound call arrives at 11:40. Your ringtone goes through a suspended AudioContext, so it plays to nobody, and no exception reaches your error handler. The ticket you receive says "the phone doesn't ring," which is among the harder bug reports to reproduce, since it only ever happens on a tab nobody has clicked.
Remote audio has the same shape and worse consequences. The <audio> element carrying the far end's MediaStream is subject to the autoplay policy like any other media element. Safari carves out an exemption — MediaStream-backed media autoplays "if the web page is already capturing" — but before the call is answered you aren't capturing yet, so the exemption doesn't apply at the moment you need it. An uncaught rejection from remoteAudio.play() gets you a call that signals as connected while the agent hears nothing, which is the classic one-way audio report with a browser-policy cause rather than a network one.
The design rule that removes most of this: do all audio setup inside the answer button's click handler. That one click is a genuine user gesture, and it's enough for everything at once — resume the AudioContext, await remoteAudio.play(), and call getUserMedia(). The permission prompt and any NotAllowedError or NotReadableError then surface at a point in the UI where you can actually show the user something, instead of during page load where the failure is invisible.
Ringing is the one piece that can't be gated that way, since audible ring has to precede any gesture. Nothing you write will force audio out of a tab that has never been touched. What you can do is call the unlock helper on the first interaction of the session so most tabs are unlocked long before a call arrives, and fall back to a visible in-page ring plus the Notification API for the ones that aren't.
The warning is identical across engines because the cause is. Tone.js issue #341, filed 4 May 2018, is the canonical case: the library built its AudioContext at import time, so the warning appeared before a single line of user code ran. Tone's README now leads with it — "Browsers will not play any audio until a user clicks something (like a play button)" — and the documented fix is await Tone.start() inside a click handler. Unity WebGL builds, Godot HTML5 exports, PlayCanvas and GameMaker all land in the same place, and every one of those forums arrives at the same answer: a "Start" or "Play" splash screen whose only real job is to collect the first click. Find where your engine constructs its context, then make sure something resumes it on the first gesture.
What is an AudioContext?
An AudioContext is the Web Audio API's processing graph. You create nodes — oscillators, gain, filters, MediaStreamSource for a microphone or a WebRTC track — connect them, and route the result to ctx.destination, which is the speakers. Any browser audio work beyond a plain <audio> element runs through one.
How do I allow audio in my browser? If you're hitting this on someone else's site, click anywhere on the page first; one click usually starts the audio. In Chrome, the icon to the left of the URL opens site settings, where Sound can be switched from Automatic to Allow. Reload afterward.
Can I just ignore the AudioContext warning? Often, yes. If you create the context at load and resume it on a click, Chrome still prints the warning once while audio works fine, because creating a context without a gesture is legal and only starting one isn't. Treat it as a real bug only when sound is actually missing.
Does muting or setting gain to zero avoid the error?
No. Muted autoplay is always allowed for <video> and <audio> elements, but that exemption doesn't reach the Web Audio graph. A suspended AudioContext emits nothing and doesn't advance currentTime at any gain value. Resume it first, then set gain to whatever you want.
Does getUserMedia need a user gesture too? No specification requires it, though you should write as if one did. Chrome prompts for the microphone whenever you call it, and prompts fired on page load are denied far more often than prompts fired from a click. Requesting the mic in the same handler that resumes your context solves both problems.
Chrome's autoplay policy is one entry on a list that also holds iOS interruption states, permission prompts, device-change events, and whatever ships in the next browser release. Each is a few lines of code plus a support ticket you only meet in production. Keeping that list handled is what Alloqui is: you bring your own Twilio or Plivo keys, you drop a <Dialer /> component into your React app, and the browser plumbing underneath it is ours to maintain.