Skip to main content
Manifest V3 service workers have no DOM. When background logic needs DOM parsing, audio playback, clipboard access, or another window-only API, Chrome’s answer is an offscreen document: an invisible extension page you create on demand with chrome.offscreen.

Setup

Request the permission in manifest.json:
manifest.json
{
  "permissions": ["offscreen"]
}
The offscreen page is not a manifest entrypoint, so declare it through the pages/ special folder and Extension.js compiles it like any other HTML entry:
pages/
└── offscreen.html
pages/offscreen.html
<!doctype html>
<html>
  <body>
    <script src="./offscreen.ts" type="module"></script>
  </body>
</html>

Create, reuse, close

Chrome allows one offscreen document per extension, and calling createDocument while one exists throws. The reliable pattern is an ensure function that checks for an existing document first:
background.ts
let creating: Promise<void> | null = null;

async function ensureOffscreen() {
  const contexts = await chrome.runtime.getContexts({
    contextTypes: ["OFFSCREEN_DOCUMENT" as chrome.runtime.ContextType],
  });
  if (contexts.length > 0) return;

  if (!creating) {
    creating = chrome.offscreen.createDocument({
      url: "pages/offscreen.html",
      reasons: [chrome.offscreen.Reason.DOM_PARSER],
      justification: "Parse HTML strings that the service worker cannot",
    });
  }
  await creating;
  creating = null;
}
The creating lock matters: two events can race into ensureOffscreen before the document exists, and the second createDocument call would throw. Close it when the work is done to free memory:
await chrome.offscreen.closeDocument();

Choosing a reason

The reasons array tells Chrome why the document exists. Common values:
ReasonUse case
DOM_PARSERParse or sanitize HTML strings
AUDIO_PLAYBACKPlay sound from the background
CLIPBOARDRead or write the clipboard
DOM_SCRAPINGExtract data from rendered markup
BLOBSCreate and manage blob URLs
USER_MEDIAAccess getUserMedia for recording
One behavior worth knowing: with AUDIO_PLAYBACK, Chrome closes the document automatically about 30 seconds after audio stops playing. For other reasons the document lives until you close it or the extension unloads.

Talking to the document

Offscreen documents use standard runtime messaging. Scope your messages so other surfaces ignore them:
pages/offscreen.ts
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message.target !== "offscreen") return;
  const doc = new DOMParser().parseFromString(message.html, "text/html");
  sendResponse({ title: doc.title });
  return true;
});

Firefox

Firefox does not implement chrome.offscreen. Its Manifest V3 background runs as an event page that already has DOM access, so the same DOM work runs directly in the background script there. Use browser-specific manifest fields and a capability check (typeof chrome.offscreen !== "undefined") to branch.

See also