DOMException play() Failed: Fix Autoplay Blocks
Catch the play() promise and start muted, then unmute on a click — most DOMException play() failures come from autoplay policy, not broken media..
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
- ✓One <video> element you can edit and reload
- ✓A browser console where you can read promise rejections
- ✓A fresh browser profile with cleared site engagement
- Audible autoplay needs a gesture or engagement history — start muted or wait for a click before playing with sound
- play() returns a promise: catch it and read err.name instead of letting Uncaught (in promise) pile up
- NotAllowedError means policy blocked you; AbortError means your own pause() or src swap interrupted the request
- iOS needs the playsinline attribute, and every player needs a visible play-button fallback
Imagine a theater that won't start a loud movie until someone in the audience nods — that's the browser protecting you from surprise noise. A silent preview (muted video) may roll freely, but sound waits for a nod (a click or tap). play() is you asking the projectionist to start; the promise is their answer, yes or a reason. If you shout 'stop' mid-sentence, they report the interruption — that's AbortError, and it's your own timing, not a broken projector.
The video never starts, and the console blames a DOMException from play(). You re-encode the file, swap the CDN, and question the codec — but the bytes were fine all along. The browser simply refused to play sound nobody asked for, and your code never handled the refusal.
Modern browsers gate audible autoplay behind user engagement: no gesture, no sound. Muted autoplay stays allowed, play() returns a promise that rejects with a named error, and pausing at the wrong instant aborts the request you just made. Each behavior is documented and stable, yet together they read like chaos when the promise floats unhandled and the UI shows a frozen frame.
This guide maps the territory: the autoplay policies and their muted exception, which gestures actually unlock sound, promise handling that turns rejections into UI states, the AbortError race your own toggle creates, and feature detection that survives browser differences. You'll leave with one robust pattern that plays everywhere it can and explains itself everywhere it can't.
Autoplay Policies: Why Browsers Block Sound by Default
Browsers block audible autoplay for good reasons: surprise sound drives users away, mobile data costs real money, and background tabs competing for speakers help nobody. Since Chrome 66, Firefox 66, and Safari 11, the default is consistent — a page may not start audible playback until the visitor interacts or builds engagement history with the site. Chrome scores this with a Media Engagement Index; Safari and Firefox lean on per-site interaction records. Fresh profiles, incognito windows, and new visitors start at zero, which is exactly when marketing pages get their most views.
That asymmetry explains the classic confusion: it works for the team and fails for strangers. Developers visit their own pages daily, accumulating engagement that forgives audible autoplay, while every new visitor meets the strict default. Testing in your daily browser is therefore the worst way to verify video behavior — a fresh profile with cleared data shows what newcomers actually get. Add that profile to your release checklist and half of all 'works for me' video tickets vanish.
The policy is not a ban on motion — it's a ban on surprise sound. Muted playback, captions, and poster frames all stay available, and audible playback unlocks the moment engagement exists. Design for that shape: motion on load (muted), sound on invitation (gesture). Pages built this way never fight the browser, load faster on mobile, and convert better because the visitor chooses sound instead of scrambling for the mute key.
Muted Autoplay: The Allowed Path and Its Limits
Muted autoplay is the doorway the policies leave open: a video with no audible track (or the muted flag set before play) may start without engagement in Chrome, Safari, and Firefox. The key detail is timing — muted must be in effect before the play request, via the muted attribute in markup or video.muted = true in script ahead of the call. Setting it after play() starts is too late for the policy check and fails on strict browsers. This ordering bug is the most common reason 'but it IS muted' complaints don't reproduce the allowance.
But muted has limits newcomers trip over. Programmatic unmute (video.muted = false on a timer) counts as audible playback and needs a gesture just like fresh play with sound. Volume fades from 0 via script hit the same wall. And some locked-down contexts — data-saver modes, low-power states, background tabs — can refuse even muted playback, which is why the muted call needs its own catch and fallback, not blind confidence. The snippet with this section shows the full shape: muted boot with error handling, plus a click-driven unmute that the policy honors.
Treat muted as chapter one of the user journey, not the whole story. Pair every auto-starting video with an obvious sound control — an unmute button with a speaker icon, placed over the video, keyboard-focusable. Analytics worth keeping: how many visitors unmute tells you whether sound adds value. Teams that measure this often discover most viewers prefer silence with captions, and the 'blocked autoplay' crisis quietly becomes a preference the design already serves.
User Gestures: What Counts as Interaction
A qualifying gesture is narrower than most developers assume. Click, keydown (a real key press), and touchend on the page create user activation that audible play() may consume. Scrolling, mouse movement, page load, timers, and IntersectionObserver callbacks do not — no matter how engaged the visitor looks. Activation is also transient: it expires after a short window (a few seconds in Chrome), so the play() call must run while it's fresh. A click that fetches data for 30 seconds before playing can arrive with dead activation and throw.
This expiry explains the maddening 'it works in the handler but not after my fetch' reports. The handler-to-play path matters: direct play in the click listener almost always qualifies, while long async detours risk losing the privilege. Keep the path short — start muted playback instantly in the gesture, then upgrade to sound as data arrives — or re-request play from a second user action. When in doubt, bind sound to the gesture synchronously and treat everything async as unprivileged until proven otherwise.
Note the frame the policies ignore: keyboard users. A keydown-driven play must work as well as click-driven play, and the fallback button must be focusable and labeled. Autoplay discussions usually center on mouse and touch, but the same activation rules cover keydown — test the play button with Tab plus Enter, not just pointer clicks. Accessible and policy-compliant turn out to be the same implementation.
play() short and synchronous.play() Returns a Promise: Handle Rejection Every Time
Since the autoplay policy era, play() returns a genuine Promise: it resolves when playback actually begins and rejects with a DOMException when it can't. The rejection name is the diagnosis. NotAllowedError means policy or permission refused the request — the common autoplay block. AbortError means the request was interrupted, usually by your own pause(), load(), or source swap racing it. NotSupportedError means the media or codec can't play at all. Three names, three fixes — but only if your code reads the answer instead of dropping it on the floor.
Dropped promises are the epidemic. video.play() without .catch() or await produces 'Uncaught (in promise) DOMException' console lines that look like browser bugs and flood error trackers with unactionable noise. Worse, the UI never learns playback failed, so users stare at a poster frame with no play button and leave. The fix is cultural as much as technical: treat play() like a network call — always awaited, always caught, always reflected in UI state. Linters that flag floating promises earn their keep on media pages.
Build the rejection into the component's state machine: idle, playing, blocked (show overlay), interrupted (retry once), unsupported (show message). The snippet here shows that branching in miniature. Once the states exist in the UI, autoplay stops being an error and becomes a flow — blocked shows a beautiful tap-to-play card, and support tickets change from 'video broken' to silence. Users don't mind pressing play; they mind a player that fails without telling them.
play() rejections were 80% of one app's tracked errors. Catching them and rendering states cut tracker noise to zero in a release.play(), branch the UI on err.name, and turn blocked into a tap-to-play state instead of an error.AbortError on pause() and Feature-Detecting Playback
AbortError is the rejection developers misread most. It doesn't mean the browser forbade playback — it means your own code canceled the request before it settled. The classic sequence: play() starts loading, the user (or your effect hook) calls pause() 50ms later, the pending play promise rejects with AbortError, and the console line gets filed as an autoplay failure. Variants include calling load(), swapping src, or removing the element mid-request — anything that invalidates the in-flight play. The permission was fine; the timing wasn't.
React and framework effects manufacture this race on clean mounts. An effect that plays on mount with a cleanup that pauses on unmount will abort whenever StrictMode double-invokes effects in development — every developer sees AbortError and assumes breakage that production never shows. The robust answer is serialization: a single async toggle that awaits play() before any pause(), a busy flag that drops mash-clicks, and effect cleanups that check whether playback actually started. The snippet here is the whole pattern in twenty lines.
Feature detection belongs in the same breath. Confirm typeof video.play === 'function', confirm the call returns a thenable before chaining, and probe video.canPlayType('video/mp4; codecs="avc1.42E01E"') for codec support before blaming policy. Old embedded webviews and smart-TV browsers fail these checks while modern browsers pass — detect, don't sniff user agents. With detection plus serialization, the player degrades gracefully everywhere instead of throwing mysteriously somewhere.
play() before pause(), guard rapid toggles, and detect features instead of browsers.The Checklist That Ends play() Failures
Run this order at the first frozen frame. One: read the rejection name in the console — NotAllowedError is policy, AbortError is interruption, NotSupportedError is codec. Two: retry muted (video.muted = true before play). Muted starting proves policy was the story; muted failing too points at interruption or format. Three: move the call into a direct click handler. Starting there proves the old call site lacked activation — timers and load handlers never qualify.
Four: serialize the toggle — await play() before pause(), add the busy flag, and retest mash-clicking. AbortError disappearing confirms the race. Five: check mobile specifics — playsinline plus webkit-playsinline present, retest on a real device, and confirm canPlayType covers your codec. Six: only now consider the bytes — re-encode or CDN-purge after the five code-level causes are ruled out, not before.
Encode the pattern in a shared component so every page inherits it: muted-first boot, caught promises mapped to UI states, gesture-bound unmute, serialized toggle, inline attributes, and a tap-to-play fallback. Review media PRs against that list the way you'd review auth code. Autoplay policy isn't going away — but with the component doing the right thing by default, no team pays the 2-day tuition twice.
The Sound-On Hero Video That Failed for Every New Visitor for 2 Days
play() failed in the console. Returning visitors and the whole dev team saw perfect playback. The pattern — works for us, broken for strangers — pointed everywhere except the true cause for 2 days.video.play() with sound on page load and ignored the returned promise. Fresh visitors had no engagement history, so Chrome, Safari, and Firefox all rejected with NotAllowedError and the video sat on its first frame. The team's own browsers had high engagement scores, so the video played for them and the bug looked environment-specific. Two days went to re-encoding and CDN purges before someone read the unhandled rejection's name.- Read err.name before touching assets — NotAllowedError is a permission verdict, not a broken file.
- Fresh-profile testing with cleared engagement data catches policy blocks that loyal dev browsers forgive.
- Every
play()needs a visible fallback UI; a frozen frame with no button is a dead end for users.
play() call directly into a click, keydown, or touchend handler and retest. If it starts there, the old call site lacked activation — timers and load handlers don't qualify. Keep the handler-to-play path short; long fetch chains can let transient activation expire before play runs.play() promise before allowing pause(), and disable the button while pending. If rapid clicking stops throwing, the race was the bug. Never let pause() fire while a play() promise from the same element is still unsettled.| File | Command / Code | Purpose |
|---|---|---|
| autoplay-safe.js | async function startHeroVideo(id) { | Autoplay Policies |
| muted-unmute.js | const video = document.getElementById('hero'); | Muted Autoplay |
| gesture-play.js | const video = document.getElementById('trailer'); | User Gestures |
| promise-play.js | async function playWithSound(video) { | play() Returns a Promise |
| toggle-safe.js | const video = document.getElementById('clip'); | AbortError on pause() and Feature-Detecting Playback |
Key takeaways
play() before pause() so rapid toggles stop faking permission errors.Common mistakes to avoid
5 patternsCalling play() with sound on page load
Ignoring the promise that play() returns
play() call, and branch on err.name: NotAllowedError means blocked, AbortError means interrupted. Never let the promise float — an unhandled rejection also pollutes error tracking.Unmuting or playing from a timer instead of a gesture
Calling pause() while play() is still pending
play() promise before pausing, or guard with a flag. When the user can mash the toggle, chain the actions through one async function so pause never races an in-flight play.Forgetting playsinline on iOS
play() behavior diverges from every other platform. One attribute aligns iOS with the desktop flow the team already tested.Interview Questions on This Topic
Why do browsers block autoplay with sound?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's Browser. Mark it forged?
6 min read · try the examples if you haven't