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.
Table of Contents
An extension may have exactly one offscreen document at a time, and chrome.offscreen.createDocument rejects if one already exists. Because the service worker that created it can be evicted at any moment, “does one already exist” is not a question you can answer from a variable — and the naive try { create } catch { ignore } swallows real errors along with the expected one. This guide is part of offscreen documents and DOM access.
What an offscreen document is for
It is a hidden extension page with a real DOM, created by the worker, invisible to the user, and subject to the extension’s CSP. It exists because a service worker has no document, no Audio, no DOMParser and no URL.createObjectURL — and MV3 removed the background page that used to provide them.
Step-by-step
1. Declare the permission and the document
1{
2 "permissions": ["offscreen"],
3 "background": { "service_worker": "sw.js", "type": "module" }
4}
Execution context: parsed at install. There is no manifest entry for the document itself — it is created at runtime, and the HTML file simply has to exist in the package.
2. Create it idempotently
The reliable check is chrome.runtime.getContexts, which asks the browser what exists rather than relying on worker memory.
1const OFFSCREEN_URL = "offscreen/host.html";
2let creating = null; // in-flight promise, per worker lifetime
3
4export async function ensureOffscreen() {
5 const contexts = await chrome.runtime.getContexts({
6 contextTypes: ["OFFSCREEN_DOCUMENT"],
7 documentUrls: [chrome.runtime.getURL(OFFSCREEN_URL)],
8 });
9 if (contexts.length) return;
10
11 if (creating) return creating; // two callers in the same worker
12 creating = chrome.offscreen.createDocument({
13 url: OFFSCREEN_URL,
14 reasons: ["CLIPBOARD"],
15 justification: "Write the exported report to the clipboard on the user's request.",
16 }).finally(() => { creating = null; });
17 return creating;
18}
Execution context: the service worker. The creating guard handles two concurrent callers within one worker lifetime; getContexts handles the case where the document survived a worker eviction. Both are needed — neither alone is sufficient.
chrome.runtime.getContexts is available from Chrome 116. On older builds the fallback is clients.matchAll() from the worker’s own service-worker globals, which lists the document among the worker’s clients.
3. Give an honest reason and justification
reasons is an enum the browser validates, and the justification string is read by reviewers. Both should describe the actual use.
CLIPBOARD— reading or writing the clipboard.AUDIO_PLAYBACK— playing sound.DOM_PARSER— parsing HTML withoutinnerHTML.BLOBS— creating object URLs.USER_MEDIA,DISPLAY_MEDIA,WEB_RTC— capture and streaming.IFRAME_SCRIPTING,TESTING,LOCAL_STORAGE,WORKERS,BATTERY_STATUS,MATCH_MEDIA,GEOLOCATION— the remaining specific cases.
Listing several reasons is legitimate when the document genuinely does several things, and suspicious when it is a hedge. Pick the ones you use.
4. Talk to it, and know when it is ready
1export async function askOffscreen(message, timeoutMs = 5000) {
2 await ensureOffscreen();
3 return Promise.race([
4 chrome.runtime.sendMessage({ target: "offscreen", ...message }),
5 new Promise((_, r) => setTimeout(() => r(new Error("offscreen timeout")), timeoutMs)),
6 ]);
7}
Execution context: the service worker. Note the target field: the offscreen document shares chrome.runtime.onMessage with every other extension context, so both sides must filter or they will answer each other’s messages — the registry pattern in wrapping message passing in promises.
1// offscreen/host.js
2chrome.runtime.onMessage.addListener((msg, _sender, respond) => {
3 if (msg?.target !== "offscreen") return false;
4 handle(msg).then(respond, (err) => respond({ error: String(err) }));
5 return true;
6});
Execution context: the offscreen document, a full DOM context with no visible window. It has the extension’s origin and permissions but no access to chrome.tabs-style UI APIs it does not need.
5. Close it when the work is done
An open offscreen document holds a renderer process. Leaving one open for the browser session is a real memory cost for a feature the user invoked once.
1export async function closeOffscreen() {
2 const contexts = await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] });
3 if (contexts.length) await chrome.offscreen.closeDocument();
4}
Execution context: the service worker. Calling closeDocument when none exists rejects, which is why the check comes first — and why a bare catch here would hide the more interesting failures.
For a document used in bursts, an idle timer inside the document itself is tidier than tracking usage from the worker:
1// offscreen/host.js
2let idle;
3function touch() {
4 clearTimeout(idle);
5 idle = setTimeout(() => window.close(), 30_000);
6}
Execution context: the offscreen document, which has its own event loop and is not subject to the worker’s idle timer. window.close() from inside is equivalent to the worker calling closeDocument.
One document, several features
Because an extension may hold only one offscreen document, any extension that uses it for more than one thing needs a small amount of shared structure — otherwise the clipboard feature closes the document while the audio feature is mid-playback.
The answer is a lease count owned by the document itself. Each feature takes a lease when it starts and releases it when it finishes; the document closes itself only when no leases remain.
1// offscreen/host.js
2const leases = new Map(); // feature -> count
3let idleTimer;
4
5function acquire(feature) {
6 leases.set(feature, (leases.get(feature) ?? 0) + 1);
7 clearTimeout(idleTimer);
8}
9
10function release(feature) {
11 const n = (leases.get(feature) ?? 1) - 1;
12 n > 0 ? leases.set(feature, n) : leases.delete(feature);
13 if (leases.size === 0) idleTimer = setTimeout(() => window.close(), 15_000);
14}
Execution context: the offscreen document. Keeping the count inside the document rather than in the worker is deliberate — the worker may be evicted mid-feature and lose its count, whereas the document is alive for exactly as long as the count matters.
The reasons passed to createDocument must cover every feature that might use the document, because the reasons are fixed at creation and cannot be amended. If the clipboard feature creates the document with ["CLIPBOARD"] and the audio feature then tries to play sound in it, the playback may be refused. Create with the union of reasons your extension uses, and keep the justification accurate for all of them.
1chrome.offscreen.createDocument({
2 url: OFFSCREEN_URL,
3 reasons: ["CLIPBOARD", "AUDIO_PLAYBACK", "DOM_PARSER"],
4 justification: "Clipboard export, notification sounds and HTML parsing for article previews.",
5});
Execution context: the service worker. Listing reasons you use is legitimate; listing reasons you do not is the kind of thing a reviewer asks about.
Cross-browser variation
- Chrome / Edge:
chrome.offscreenis available from Chrome 109,getContextsfrom 116. Exactly one document at a time; a secondcreateDocumentrejects with “Only a single offscreen document may be created”. - Firefox: no
offscreenAPI. Firefox’s MV3 background context is an event page with a real DOM, so the APIs an offscreen document exists to provide are simply available in the background script — the portability approach is in offscreen alternatives in Firefox and Safari. - Safari: no
offscreenAPI either. Safari’s background context is a service worker like Chrome’s, so the DOM gap is real there and the workarounds are narrower — usually an extension page in a tab, or doing the work in a content script. - All three: the offscreen document is not a place to run long background work. It is a DOM host; the scheduling still belongs to the worker and its alarms.
Verification
- Confirm exactly one document exists after several concurrent calls:
1await Promise.all([ensureOffscreen(), ensureOffscreen(), ensureOffscreen()]);
2(await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] })).length;
3// 1
Execution context: the service worker console. A rejection here means the creating guard is missing; a count above one is not possible, but a rejected promise that surfaced to the user is.
- Stop the worker from
chrome://extensions, then callensureOffscreen()again and confirm it reuses rather than rejects. - Confirm the document closes — either by the idle timer or explicitly — and check the browser task manager shows the process going away.
- Send a message with no
targetfield and confirm the offscreen listener ignores it.
FAQ
Can I have two offscreen documents?
No. One per extension, enforced by the browser. If two features need a DOM, they share the document and route by message type.
Does the offscreen document count as a “window” for user gestures?
No. It is not user-visible and cannot originate a gesture, so APIs that require one — permissions.request, sidePanel.open — cannot be called from it.
How long can it stay open?
Indefinitely, unless you close it. That is the difference from the worker, and the reason closing it is your responsibility rather than the browser’s.
Related
- Reading and writing the clipboard in MV3 — the most common reason to create one.
- Parsing HTML without innerHTML in MV3 — the DOM_PARSER use.
- Offscreen alternatives in Firefox and Safari — shipping the same feature where the API does not exist.
- Offscreen documents and DOM access — the parent guide.