Offscreen Documents & DOM Access

Use chrome.offscreen to get a real DOM from an MV3 service worker: creating the document, choosing the right reason, messaging it safely, closing it, and the fallbacks for Firefox and Safari.

An MV3 service worker has no document, no DOMParser, no Audio, and no navigator.clipboard — and every workaround that used to paper over that gap has been closed. The offscreen document is the sanctioned replacement: a hidden extension page that the worker creates on demand, talks to over chrome.runtime messaging, and closes when the work is finished. This guide belongs to Manifest V3 Architecture & Extension Lifecycle and covers the whole cycle, including the two constraints that catch everyone — you may only have one offscreen document at a time, and the API exists in Chrome and Edge only.

Where an offscreen document sits relative to the workerThe service worker creates a hidden document in its own renderer process, exchanges messages with it, and closes it when the DOM work is complete.Service workerno DOM, holds permissionschrome.offscreencreateDocument / closeDocumentOffscreen documenthidden renderer, full DOMmessaging is the only channel between themruntime.sendMessagetagged with a targetStructured-clonable datano DOM nodes crossResult back to the workerthen close the document
The offscreen document is a renderer, not a worker — which is exactly why it has the DOM the worker lacks.

Prerequisites checklist

  • "offscreen" permission in manifest.json. Without it chrome.offscreen is undefined, not an empty object, so feature-detect before calling.
  • An HTML file bundled in the extension to act as the document. It is an ordinary extension page and runs under the extension content security policy, so its script must be an external file.
  • A top-level chrome.runtime.onMessage listener in the service worker, registered synchronously so replies from the document are never dropped.
  • Chrome or Edge 109 or newer. Firefox and Safari have no equivalent; see the cross-browser notes below for the substitute paths.
  • A justification string describing why the DOM is needed. It is surfaced during store review, so write it for a reviewer rather than for yourself.

Manifest registration

 1{
 2  "manifest_version": 3,
 3  "name": "Feed Digest",
 4  "version": "2.4.0",
 5  "permissions": [
 6    "offscreen",      // gates the whole chrome.offscreen namespace
 7    "storage",
 8    "alarms"
 9  ],
10  "background": {
11    "service_worker": "background.js",
12    "type": "module"  // offscreen creation is promise-based; modules keep the call sites clean
13  },
14  // The document itself needs no manifest entry — it is referenced by path at creation time.
15  // It does need to be reachable, so keep it out of any exclusion list in your bundler config.
16  "web_accessible_resources": []   // deliberately empty: the document is never loaded by a page
17}

Execution context: Parsed by the browser at install time. The offscreen permission is not a runtime grant — it cannot be requested through chrome.permissions.request, so it has to be present in the shipped manifest. Firefox and Safari accept the manifest but expose no chrome.offscreen, which is why the feature detection in the next section is not optional.

1. Creating the document exactly once

The single most common bug in offscreen code is a second createDocument call. There can only be one offscreen document per extension, and a duplicate call rejects with an error rather than returning the existing document. Because the service worker is evicted routinely, a module-scope flag is not a safe record of whether the document exists — the flag disappears while the document survives.

 1// background.js
 2const OFFSCREEN_PATH = 'offscreen/offscreen.html';
 3
 4// A single in-flight promise per wake, so concurrent callers do not race into two create calls.
 5let creating = null;
 6
 7async function ensureOffscreenDocument() {
 8  if (!chrome.offscreen) {
 9    throw new Error('offscreen-unsupported');
10  }
11
12  // hasDocument() is the authoritative answer and survives worker eviction.
13  if (await chrome.offscreen.hasDocument()) return;
14
15  if (creating) {
16    await creating;
17    return;
18  }
19
20  creating = chrome.offscreen.createDocument({
21    url: OFFSCREEN_PATH,
22    reasons: [chrome.offscreen.Reason.DOM_PARSER],
23    justification: 'Parse untrusted feed markup with DOMParser to extract titles and links.',
24  });
25
26  try {
27    await creating;
28  } finally {
29    creating = null;
30  }
31}

Execution context: Extension service worker. chrome.offscreen.hasDocument() requires Chrome 116 or newer; on 109–115 the equivalent check is (await clients.matchAll()).some((c) => c.url.endsWith(OFFSCREEN_PATH)), which works because the offscreen document is a client of the worker. The creating promise guard only protects callers within a single wake — hasDocument() is what protects you across evictions. Firefox and Safari take the offscreen-unsupported branch.

The create-use-close cycle for one parse jobThe worker checks whether a document exists, creates it if not, sends the payload, receives the parsed result and closes the document.CallerService workerchrome.offscreenOffscreen documentparseFeed(markup)hasDocument()falsecreateDocument({ url, reasons, justification })loads the hidden pagesendMessage({ target: 'offscreen', markup })parsed itemscloseDocument()only when nothing else needs it
hasDocument is checked on every job because the worker's own memory of the document does not survive eviction.

2. Choosing the right reason

reasons is not decoration. The browser uses it to decide how the document is treated — most visibly, an audio-playback document is expected to be long-lived while a parser document is expected to be transient. Declaring a reason you do not use is also a review risk, because the justification you write is read against the code you shipped.

 1// The reasons that matter in practice, with the API each one unlocks.
 2const REASON_FOR_JOB = {
 3  parseHtml:   chrome.offscreen.Reason.DOM_PARSER,     // DOMParser
 4  scrapeDom:   chrome.offscreen.Reason.DOM_SCRAPING,   // querySelector over parsed markup
 5  playSound:   chrome.offscreen.Reason.AUDIO_PLAYBACK, // Audio, AudioContext
 6  readBlob:    chrome.offscreen.Reason.BLOBS,          // URL.createObjectURL, FileReader
 7  copyText:    chrome.offscreen.Reason.CLIPBOARD,      // navigator.clipboard
 8  runIframe:   chrome.offscreen.Reason.IFRAME_SCRIPTING,
 9};
10
11async function withOffscreen(job, payload) {
12  await ensureOffscreenDocument(REASON_FOR_JOB[job]);
13  return chrome.runtime.sendMessage({ target: 'offscreen', job, payload });
14}

Execution context: Extension service worker. chrome.offscreen.Reason is only readable once the offscreen permission is granted; reading it behind the feature check above avoids a TypeError on other engines. A document may declare several reasons at once, but it is created with the union of them for its whole lifetime — if you need audio for hours and a parser for milliseconds, do not share one document between the two jobs.

What each offscreen reason is forThe common reason values, the browser API each unlocks, the expected document lifetime and whether a lighter alternative exists.ReasonUnlocksExpected lifetimeLighter alternativeDOM_PARSERDOMParserMillisecondsNone in a workerDOM_SCRAPINGquerySelectorShortContent scriptAUDIO_PLAYBACKAudio, AudioContextWhile playingNone in a workerBLOBSFileReader, object URLsShortfetch + arrayBufferCLIPBOARDnavigator.clipboardOne interactionContent scriptIFRAME_SCRIPTINGSame-origin iframesTask-scopedNone in a worker
Reach for an offscreen document only when the column on the right is empty — a content script is cheaper whenever it is possible.

3. Messaging the document without crossing wires

The offscreen document shares the extension’s message bus with the popup, the options page and every content script. That means the worker’s own onMessage listener receives the document’s messages, and the document receives the worker’s broadcasts. Every message therefore needs a target tag, and both listeners need to ignore anything not addressed to them. This is the same discipline described in message passing architecture, applied to a context that cannot be reached any other way.

 1// offscreen/offscreen.js — external file, loaded by offscreen.html
 2chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
 3  if (message?.target !== 'offscreen') return;   // not for us — let other listeners see it
 4
 5  (async () => {
 6    switch (message.job) {
 7      case 'parseHtml': {
 8        const doc = new DOMParser().parseFromString(message.payload, 'text/html');
 9        const items = [...doc.querySelectorAll('item, entry')].map((node) => ({
10          title: node.querySelector('title')?.textContent?.trim() ?? '',
11          link: node.querySelector('link')?.getAttribute('href')
12            ?? node.querySelector('link')?.textContent?.trim() ?? '',
13        }));
14        sendResponse({ ok: true, items });
15        break;
16      }
17      default:
18        sendResponse({ ok: false, error: `unknown job: ${message.job}` });
19    }
20  })();
21
22  return true;   // keep the channel open for the async reply
23});

Execution context: Hidden extension page in its own renderer process. DOMParser, document and window are all available; almost no chrome.* API is, beyond chrome.runtime messaging — so read and write state in the worker and pass it in, rather than calling chrome.storage from here. The return true requirement is identical to the worker’s, and forgetting it produces the same port-closed error. Parsing with DOMParser does not execute scripts in the parsed markup, which is what makes this safe for untrusted feed content.

4. Closing the document, and what happens if you do not

An open offscreen document is a live renderer process. It costs memory, it keeps a client attached to your worker, and Chrome makes no promise about keeping it alive indefinitely — so the only correct model is: create on demand, close when the job is done, and be prepared for it to be gone next time.

 1// background.js — close as soon as no job needs the document
 2let openJobs = 0;
 3
 4async function runOffscreenJob(job, payload) {
 5  await ensureOffscreenDocument(REASON_FOR_JOB[job]);
 6  openJobs += 1;
 7  try {
 8    return await chrome.runtime.sendMessage({ target: 'offscreen', job, payload });
 9  } finally {
10    openJobs -= 1;
11    if (openJobs === 0 && (await chrome.offscreen.hasDocument())) {
12      await chrome.offscreen.closeDocument();
13    }
14  }
15}

Execution context: Extension service worker. openJobs is a within-wake counter, so it is reset by eviction — which is safe here, because the finally block always re-checks hasDocument() before closing. Audio playback is the exception to close-immediately: a document created with AUDIO_PLAYBACK should stay open for as long as the sound is playing, which is covered in playing audio from an extension.

Two documents with very different lifetimesA parser document exists for a few milliseconds inside one job, while an audio document stays open for the whole duration of playback.job startsminutes laterParser d…a few msParsed a…renderer …Audio doc createdAUDIO_PLAYBACKPlayingstays open on purposeClosed o…explicitlyclose it in the finally blockclose it when playback ends
Match the document's lifetime to its reason — a parser left open is just a leaked renderer process.

5. What to do when the API is absent

Firefox and Safari never expose chrome.offscreen, so any feature built on it needs a second path or a clear degradation. Which substitute applies depends on why you wanted the DOM in the first place, and two of the four cases have genuinely good answers.

 1// dom-work.js — one entry point, three implementations behind it
 2export async function parseMarkup(markup) {
 3  if (chrome.offscreen) {
 4    return runOffscreenJob('parseHtml', markup);
 5  }
 6
 7  // Firefox event pages have a real DOM in the background context itself.
 8  if (typeof DOMParser !== 'undefined') {
 9    const doc = new DOMParser().parseFromString(markup, 'text/html');
10    return { ok: true, items: extractItems(doc) };
11  }
12
13  // Last resort: borrow a DOM from a tab the user already granted access to.
14  const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
15  if (!tab?.id) return { ok: false, error: 'no-dom-available' };
16
17  const [{ result }] = await chrome.scripting.executeScript({
18    target: { tabId: tab.id },
19    func: (src) => {
20      const doc = new DOMParser().parseFromString(src, 'text/html');
21      return [...doc.querySelectorAll('item, entry')].map((n) => ({
22        title: n.querySelector('title')?.textContent?.trim() ?? '',
23        link: n.querySelector('link')?.getAttribute('href') ?? '',
24      }));
25    },
26    args: [markup],
27  });
28  return { ok: true, items: result };
29}

Execution context: Shared module imported by the service worker. The second branch runs in Firefox’s background context, which is an event page with DOM access — the reason offscreen documents were never needed there. The third branch needs the scripting permission plus access to the target tab, and it is a genuine trade-off: the parse happens in a page you do not control, so treat anything it returns as untrusted. See cross-browser API compatibility for the namespace details.

MV3 constraints to design around

  • One document per extension. A second createDocument call rejects. Always gate on hasDocument(), never on a module-scope flag.
  • No chrome.* beyond messaging. Treat the document as a pure function of the message you send it: data in, data out, state stays in the worker.
  • No DOM nodes across the boundary. Only structured-clonable values survive the message channel, so serialise before replying.
  • The document is not a tab. It never appears in chrome.tabs.query, has no tabId, and cannot be targeted with chrome.scripting.
  • Chrome and Edge only, 109+. chrome.offscreen is undefined elsewhere, so every call site needs a feature check.
  • Reasons are reviewed. The justification string is read during store review against the APIs your code actually calls.

Cross-browser notes

Firefox’s MV3 background context is an event page, so DOMParser, Audio and document are available directly and no offscreen document is required — the substitute is simply “do it inline”. Safari runs a service worker like Chrome but ships no offscreen API, which leaves a content script or an extension page in a real window as the only DOM-bearing contexts. A thin capability module keeps the branch in one place:

1type DomHost = 'offscreen' | 'background' | 'tab' | 'none';
2
3export function domHost(): DomHost {
4  if (typeof chrome !== 'undefined' && chrome.offscreen) return 'offscreen';
5  if (typeof DOMParser !== 'undefined') return 'background';
6  if (typeof chrome !== 'undefined' && chrome.scripting) return 'tab';
7  return 'none';
8}

Execution context: Shared utility, evaluated in whichever background context the engine provides. Resolving the host once at module load keeps the rest of the codebase free of engine checks, and the 'none' case gives you a single place to disable the feature and tell the user why.