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.

Published September 18, 2026 Updated September 18, 2026 8 min read
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.

Where the missing DOM APIs live nowFour contexts — service worker, offscreen document, extension pages and content scripts — showing which DOM capabilities each one has.Service workerno document, no Audio, no Blob URLswhere your logic livesOffscreen documentfull DOM, hidden, one at a timethe fill-in, created on demandPopup / options / panelfull DOM, but user-visiblecannot be opened programmatically at willContent scriptthe page's DOM, not yourswrong origin, wrong lifetime
The offscreen document exists to fill exactly the gap in the first row, without showing the user a window.

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 without innerHTML.
  • 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.

Create, use, close — across a worker evictionThe worker checks getContexts before creating, the document handles a message, the worker is evicted, and a later call finds the existing document rather than failing to create a second.Service workergetContextsOffscreen documentClipboardany OFFSCREEN_DOCUMENT?[] — nonecreateDocument({reasons:['CLIPBOARD']})sendMessage({target:'offscreen'})navigator.clipboard.writeTextworker evictedany OFFSCREEN_DOCUMENT?[one] — reuse it
The document outlives the worker that created it, which is exactly why the existence check cannot be a variable.

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.

Several features sharing one offscreen documentClipboard, audio and parsing features each acquire and release a lease on the shared document, which closes itself after an idle period once no leases remain.Clipboard exportacquire → releaseNotification soundacquire → releaseArticle previewacquire → releaseall leases held by the document itselfLease mapfeature → countCount reaches zerostart idle timerwindow.close()after 15 s idle
The document decides when to close — no single feature knows whether the others are still using it.

Cross-browser variation

  • Chrome / Edge: chrome.offscreen is available from Chrome 109, getContexts from 116. Exactly one document at a time; a second createDocument rejects with “Only a single offscreen document may be created”.
  • Firefox: no offscreen API. 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 offscreen API 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

  1. 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.

  1. Stop the worker from chrome://extensions, then call ensureOffscreen() again and confirm it reuses rather than rejects.
  2. Confirm the document closes — either by the idle timer or explicitly — and check the browser task manager shows the process going away.
  3. Send a message with no target field 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.

Other MV3 Architecture & Extension Lifecycle Resources