Playing Audio from an Extension

Play a sound from an MV3 service worker: why Audio is unavailable, creating an AUDIO_PLAYBACK offscreen document, keeping it open only while playing, and the Firefox and Safari fallbacks.

Published July 25, 2026 Updated July 25, 2026 9 min read
Table of Contents

new Audio("alert.mp3").play() in a service worker throws Audio is not defined, and there is no flag or permission that brings it back — a worker has no DOM and therefore no media elements. The supported path is an offscreen document created with the AUDIO_PLAYBACK reason, which is also the one offscreen case that is deliberately long-lived. This page is part of Offscreen Documents & DOM Access and covers playback that starts promptly, stops cleanly, and does not leak a renderer process.

Root cause: the worker has no media stack at all

Audio, AudioContext, HTMLMediaElement and navigator.mediaDevices are all DOM APIs, present only in a document. The worker can fetch the bytes of a sound file, decode nothing, and play nothing. An offscreen document is a real document, so it has the whole media stack — and because it is invisible and untabbed, the user never sees where the sound is coming from, which makes correct start and stop behaviour a usability requirement rather than a nicety.

The path from an event to an audible soundThe worker receives an event, ensures an audio-capable offscreen document exists, sends it a play command, and closes the document when playback ends.Alarm or messagesomething to announceensureAudioDocumentAUDIO_PLAYBACK reasonpostMessage playurl + volumethe document reports back when the sound finishesaudio.onendedin the documentWorker notifiedruntime.sendMessagecloseDocument()renderer released
Four hops, and the last one is the one people forget — an audio document left open is a renderer process left running.

Step 1. Create the document with the audio reason

AUDIO_PLAYBACK is not interchangeable with the other reasons. Declaring DOM_PARSER and then playing audio works in the sense that the code runs, but it misrepresents the document to the browser and to whoever reviews your justification string.

 1// background.js
 2const AUDIO_DOC = "offscreen/audio.html";
 3
 4async function ensureAudioDocument() {
 5  if (!chrome.offscreen) throw new Error("offscreen-unsupported");
 6  if (await chrome.offscreen.hasDocument()) return;
 7
 8  await chrome.offscreen.createDocument({
 9    url: AUDIO_DOC,
10    reasons: [chrome.offscreen.Reason.AUDIO_PLAYBACK],
11    justification: "Play a short notification sound when a background sync completes.",
12  });
13}

Execution context: Extension service worker. hasDocument() is the only reliable existence check across evictions, and it matters more here than elsewhere because an audio document can legitimately outlive several worker wakes. There is still only one offscreen document per extension, so an extension that both parses markup and plays sound must either share one document declaring both reasons or serialise the two jobs — sharing is usually wrong, because the parser wants to close immediately and the audio does not.

Step 2. Keep the playing document honest

The document is a plain extension page. Its script owns one Audio element, reports when playback ends, and never assumes another play command will not arrive mid-sound.

 1// offscreen/audio.js
 2let current = null;
 3
 4chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
 5  if (message?.target !== "offscreen-audio") return;
 6
 7  if (message.type === "PLAY") {
 8    // Replace any sound in flight rather than layering two alerts.
 9    if (current) { current.pause(); current.currentTime = 0; }
10
11    current = new Audio(chrome.runtime.getURL(message.path));
12    current.volume = Math.min(Math.max(Number(message.volume ?? 1), 0), 1);
13
14    current.addEventListener("ended", () => {
15      current = null;
16      void chrome.runtime.sendMessage({ target: "worker", type: "AUDIO_ENDED" });
17    }, { once: true });
18
19    current.play().then(
20      () => sendResponse({ ok: true }),
21      (error) => sendResponse({ ok: false, error: String(error) }),
22    );
23    return true;
24  }
25
26  if (message.type === "STOP") {
27    if (current) { current.pause(); current = null; }
28    sendResponse({ ok: true });
29    return false;
30  }
31});

Execution context: Hidden extension page, its own renderer. chrome.runtime.getURL is required — a bare relative path resolves against the document URL and happens to work, but an absolute extension URL is what keeps the code identical if the document moves. play() returns a promise that rejects when the browser refuses playback, which in an offscreen document is rare but not impossible, so surfacing that rejection to the worker is what turns a silent failure into a diagnosable one. The once: true listener avoids the leak of stacking an ended handler per play.

Step 3. Close the document when the sound stops

This is the step that separates a working feature from a memory leak. The worker listens for the end-of-playback message and closes the document, with a timeout as a backstop in case the message never arrives.

 1// background.js
 2let closeTimer = null;
 3
 4chrome.runtime.onMessage.addListener((message) => {
 5  if (message?.target !== "worker" || message.type !== "AUDIO_ENDED") return;
 6  void closeAudioDocument();
 7});
 8
 9export async function playSound(path, volume = 1) {
10  await ensureAudioDocument();
11  clearTimeout(closeTimer);
12
13  const result = await chrome.runtime.sendMessage({
14    target: "offscreen-audio", type: "PLAY", path, volume,
15  });
16
17  // Backstop: if the ended message is lost, do not keep the renderer forever.
18  closeTimer = setTimeout(() => { void closeAudioDocument(); }, 30_000);
19  return result;
20}
21
22async function closeAudioDocument() {
23  clearTimeout(closeTimer);
24  if (chrome.offscreen && (await chrome.offscreen.hasDocument())) {
25    await chrome.offscreen.closeDocument();
26  }
27}

Execution context: Extension service worker. The 30-second backstop is chosen to sit just past the idle-eviction window, so it fires while the worker is still likely to be alive after a short sound; for longer audio, extend it past the expected duration. If the worker is evicted before either path runs, the document survives — which is why ensureAudioDocument checks hasDocument() rather than a flag, and why the next playSound call reuses the existing document instead of failing.

The audio document's life against the worker'sThe worker is evicted while the sound is still playing; the document keeps running and is closed by the worker's next wake.playSound()document closedDocument cr…AUDIO_PLAYBA…Sound playingdocument aliveWorker evicteddocument unaffectedended messa…or the backs…closeDocume…renderer rele…module-scope state is gone herehasDocument() is the only reliable check
The document and the worker have independent lifetimes, so neither may assume the other is still there.

Step 4. Decide what to do when there is no document to create

Audio is the offscreen feature most likely to be requested on an engine that cannot provide it, so the fallback chain deserves to be explicit rather than a thrown error. Three tiers cover every engine: an offscreen document, the background context’s own DOM, and an already-open extension page.

Where the sound can actually be playedDecision tree selecting an offscreen document in Chrome, the background context in Firefox, or an already-open extension page in Safari.Which audio-capable context does this engine give you?chrome.offscreenOffscreen documentChrome and Edge 109+Works with no UI openthe ideal caseDOM in the backgroundPlay it inlineFirefox event pageAlso works headlessno extra documentNeitherUse an open pagepopup or side panelOnly while it is openfall back to a badge
Each branch ends somewhere real — the feature degrades in reach, never into an exception.
 1// audio.js — one entry point, three implementations
 2export async function play(path, volume = 1) {
 3  if (chrome.offscreen) return playSound(path, volume);
 4
 5  if (typeof Audio !== "undefined") {
 6    const sound = new Audio(chrome.runtime.getURL(path));
 7    sound.volume = volume;
 8    return sound.play().then(() => ({ ok: true }), (e) => ({ ok: false, error: String(e) }));
 9  }
10
11  // No audio-capable background context: ask any open extension page to do it.
12  const played = await chrome.runtime.sendMessage({ target: "any-page", type: "PLAY", path, volume })
13    .catch(() => null);
14  return played ?? { ok: false, error: "no-audio-context" };
15}

Execution context: Shared module imported by whichever background context the engine provides. The second branch runs in Firefox, where Audio exists in the event page itself. The third relies on a page being open and correctly returns no-audio-context when none is — at which point the honest behaviour is to fall back to a badge or notification rather than to pretend the sound played.

Cross-browser variation

  • Chrome and Edge 109+: the offscreen path above is the supported one. AUDIO_PLAYBACK documents are expected to be long-lived, so the browser does not hurry them along — closing is entirely your responsibility.
  • Firefox 121+: no chrome.offscreen, and none is needed. The MV3 background context is an event page with a DOM, so new Audio(...) works directly in the background script.
  • Safari 17+: neither offscreen documents nor a DOM in the background. Play the sound from an extension page that is already open — the popup or a side panel — or from a content script in a tab you have access to.
  • All engines: browsers may refuse playback for policy reasons and surface it as a rejected play() promise. Never assume a resolved send means the user heard anything.

Verification

  1. Trigger the sound and confirm the offscreen document appears in the browser task manager while it plays, then disappears within a second or two of the sound ending.
  2. Trigger two sounds in quick succession. The second should replace the first rather than overlapping it.
  3. Stop the service worker from the extensions page mid-playback. The sound must continue, and the next trigger must reuse the surviving document rather than throwing.
  4. Rename the audio file so the URL is wrong. play() should reject and the worker should receive ok: false with the error text.

FAQ

Can I avoid the offscreen document by playing audio in the popup?

Yes, when the popup is open — and that is the whole limitation. The popup is destroyed on blur, so a sound started there stops the moment the user clicks away. Offscreen playback is what makes a background-triggered sound audible at all.

Does an open audio document keep the service worker alive?

No. The two lifetimes are independent: the document keeps running after the worker is evicted, and the worker can be woken later to close it. That independence is why the existence check has to ask the browser rather than consult a variable.

Should I reuse one document for audio and parsing?

Usually not. A document declares its reasons for its whole lifetime, and the parser case wants to close immediately while audio wants to stay open. Serialise the jobs, or accept that the parser work keeps a long-lived renderer alive.

Why is my sound silent with no error at all?

Check the volume clamp and the resolved URL first, then the system mixer. An offscreen document has no UI, so there is no visual cue that playback started — logging the resolved chrome.runtime.getURL value is usually what finds it.

Other MV3 Architecture & Extension Lifecycle Resources