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.
Prerequisites checklist
-
"offscreen"permission inmanifest.json. Without itchrome.offscreenisundefined, 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.onMessagelistener 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.
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.
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.
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
createDocumentcall rejects. Always gate onhasDocument(), 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 notabId, and cannot be targeted withchrome.scripting. - Chrome and Edge only, 109+.
chrome.offscreenisundefinedelsewhere, 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.
Related
- Playing audio from an extension — the long-lived document case, end to end.
- Parsing HTML without innerHTML in MV3 — safe extraction from untrusted markup.
- Service Worker Fundamentals — the lifecycle that makes
hasDocument()necessary. - Extension Security & CSP Hardening — the policy the document’s own scripts run under.
- Manifest V3 Architecture & Extension Lifecycle — the section this guide belongs to.