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.
6. Deciding whether you need an offscreen document at all
An offscreen document is a real renderer process, created on demand, with a lifecycle you have to manage and a reasons declaration a reviewer will read. Before creating one, it is worth checking whether the work genuinely needs a DOM, or only looks as if it does.
Several common cases do not. OffscreenCanvas is available in the service worker, so drawing toolbar icons, resizing images and generating thumbnails need no document. fetch and Response.blob() cover most binary handling; a data: URL covers small downloads. Parsing well-formed XML or JSON needs nothing beyond the worker’s globals, and string-based extraction is often enough for the handful of fields an extension actually reads from HTML. Timers and scheduling belong to chrome.alarms regardless.
What genuinely needs a document is short: clipboard access from a background-initiated action, audio playback, DOMParser for real HTML, URL.createObjectURL for large blobs, and media capture. If the feature is on that list, the patterns in this guide apply; if it is not, the simpler path is to keep the work in the worker and avoid a second context altogether. Fewer contexts means fewer message channels to keep straight and one less thing that can be left running after the user has finished with it.
When the answer is genuinely “yes, a DOM is needed”, keep the document’s responsibilities narrow and explicit. One module per reason — clipboard, audio, parsing — each registering its own message type, makes it obvious from the code which reasons the document needs and why, which is the same question a reviewer will ask when reading the manifest and the justification side by side.
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.
Further guides in this topic
The guides below go deeper into specific offscreen documents and dom access problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.
- Capturing Audio and Video in an Offscreen Document — Record a tab, the screen or the microphone from an MV3 extension — tabCapture stream ids, the USER_MEDIA and DISPLAY_MEDIA reasons, MediaRecorder in an offscreen document, and saving the result.
- Creating and Closing Offscreen Documents — Manage the single offscreen document an MV3 extension may have — reasons, the create/exists race, keeping it alive only as long as needed, and closing it deterministically.
- Offscreen Alternatives in Firefox and Safari — Ship features built on chrome.offscreen to Firefox and Safari — using Firefox’s DOM-capable background page, Safari’s constraints, and one adapter that picks the right host per engine.
- Reading and Writing the Clipboard in MV3 — Copy and paste from a Manifest V3 extension — why the service worker cannot, when the popup can, the offscreen CLIPBOARD reason, and the permission each path needs.
- Choosing the Right Offscreen Reason — Pick the correct chrome.offscreen reason for each task: DOM_PARSER, CLIPBOARD, AUDIO_PLAYBACK, USER_MEDIA, WORKERS, LOCAL_STORAGE, MATCH_MEDIA, GEOLOCATION and more, with lifetime rules, combining reasons and review expectations.
- Getting Geolocation in an MV3 Extension — Read the user’s location from an MV3 extension: why the service worker cannot call navigator.geolocation, the geolocation permission, an offscreen document with the GEOLOCATION reason, caching, accuracy and privacy.
- Messaging Between Offscreen Documents and the Worker — Communicate reliably between an MV3 service worker and its offscreen document: targeting messages, waiting for the document to be ready, request ids, large payloads via IndexedDB, ports for streams, and error handling.
- Running WASM and Heavy Compute Outside the Service Worker — Move CPU-heavy work out of an MV3 service worker: WebAssembly with wasm-unsafe-eval, dedicated workers from an offscreen document with the WORKERS reason, transferring buffers, cancelling, and lifetime limits.
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.