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.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The service worker creates an offscreen document and immediately sends it a message — which nobody receives, because the document’s script has not finished loading. Or the popup’s message listener answers a message meant for the offscreen document, because both listen on chrome.runtime.onMessage. Or a 20 MB image sent to the document for processing makes the round trip take seconds. An offscreen document can use only chrome.runtime among extension APIs, so messaging is its only link to the rest of the extension, and that link needs a little protocol to be reliable. This guide builds it. It belongs to offscreen documents and DOM access.

Why naive messaging fails

chrome.runtime.sendMessage broadcasts to every extension context with an onMessage listener — the service worker, popup, options page, side panel and offscreen document alike — except the sender. The first listener to call sendResponse (or return a promise in Firefox) wins. Without targeting, an offscreen-bound message can be answered by the popup, and a worker-bound message from the document can be answered by an open options page. Timing is the second problem: createDocument resolves when the document is created, but its scripts may still be loading, so a message sent immediately can arrive before the listener exists and get “Could not establish connection. Receiving end does not exist.” The third is size: messages are serialised, so large binary payloads are slow and, in some versions, need conversion to plain arrays or strings.

A reliable worker ↔ offscreen protocolMessages carry a target field so only the intended context answers; the worker waits for a ready signal after creating the document; requests carry ids; large payloads go through IndexedDB keys; streams use a port.target: 'offscreen'only it answersReady handshakeafter createDocumentRequest idsconcurrent jobsfor big or streaming dataIndexedDB handoffpass a keyPortprogress streamsError envelope{ok, error}
Target, readiness, ids, handoff — four small rules that remove most failures.

Step-by-step: a robust channel

1. Target every message

1// offscreen.js
2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
3  if (msg?.target !== "offscreen") return;               // not for us: let others answer
4  if (sender.id !== chrome.runtime.id) return;           // only our own extension
5  handle(msg).then((data) => sendResponse({ ok: true, data }), (e) => sendResponse({ ok: false, error: String(e?.message ?? e) }));
6  return true;
7});

Execution context: the offscreen document. Ignoring messages without the right target lets other contexts answer theirs, and returning nothing (not true) from non-matching branches keeps the channel free. Apply the same rule in the worker for messages coming from the document (target: "worker") and in UI pages, so no context answers a message meant for another.

2. Wait until the document is ready

 1// offscreen.js — announce readiness once listeners exist
 2chrome.runtime.sendMessage({ target: "worker", type: "offscreen:ready" });
 3
 4// sw.js
 5let readyResolve;
 6let ready = null;
 7
 8async function ensureOffscreen() {
 9  const docs = await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] });
10  if (docs.length && ready) return ready;
11  ready = new Promise((r) => (readyResolve = r));
12  if (!docs.length) {
13    await chrome.offscreen.createDocument({ url: "offscreen.html", reasons: ["DOM_PARSER"], justification: "Parse HTML" });
14  } else {
15    readyResolve();                                       // existed already (e.g. after worker restart)
16  }
17  return ready;
18}
19
20chrome.runtime.onMessage.addListener((msg) => {
21  if (msg?.target === "worker" && msg.type === "offscreen:ready") readyResolve?.();
22});

Execution context: the service worker and the offscreen document. The document’s script posts “ready” at the end of its top-level code, after its listeners are registered; the worker awaits that before sending work. If the worker restarted while the document lived on, getContexts reports it and the worker treats it as ready. This removes the “receiving end does not exist” race entirely.

Create, handshake, requestThe worker creates the offscreen document; the document loads its script, registers its listener and sends offscreen:ready; the worker sends the first request with a target and id; the document replies.Service workerOffscreen doccreateDocument()script loads, listener added{target:'worker', type:'offscreen:ready'}{target:'offscreen', id:7, op:'parse'}{ok:true, data}
No request is sent until the document says it is listening.

3. Hand off large payloads through IndexedDB

 1// sw.js — store the input, pass a key
 2export async function parseLargeHtml(html) {
 3  const key = crypto.randomUUID();
 4  await idbPut("handoff", key, html);                      // shared extension-origin IndexedDB
 5  await ensureOffscreen();
 6  const res = await chrome.runtime.sendMessage({ target: "offscreen", op: "parse", key });
 7  await idbDelete("handoff", key);
 8  if (!res.ok) throw new Error(res.error);
 9  return res.data;
10}
11
12// offscreen.js
13async function handle({ op, key }) {
14  if (op === "parse") {
15    const html = await idbGet("handoff", key);
16    const doc = new DOMParser().parseFromString(html, "text/html");
17    return { title: doc.title, text: doc.body.textContent.slice(0, 50_000) };
18  }
19}

Execution context: the service worker and offscreen document, both on the extension origin and therefore sharing IndexedDB. Passing a key instead of megabytes avoids serialising large strings or buffers through the messaging layer and keeps the message itself tiny. Blobs and ArrayBuffers store natively in IndexedDB, which makes this the right path for images and files. Delete handoff records after use so they do not accumulate.

Payload strategies by sizeInline messages, inline base64, IndexedDB handoff and a port for chunked streams compared for small JSON, medium strings, large binary data and streaming results.StrategySmall JSONMedium textLarge binaryStreamingInline messageBestFineSlowNoInline base64——+33% sizeNoIndexedDB keyOverkillGoodBestNoPort + chunksOverkillGoodGoodBest
Inline for small; hand off big; stream when results arrive over time.

4. Use a port for progress and streams

1// offscreen.js — the worker connects; the document streams progress
2chrome.runtime.onConnect.addListener((port) => {
3  if (port.name !== "offscreen-job") return;
4  port.onMessage.addListener(async ({ op, key }) => {
5    for await (const progress of runJob(op, key)) port.postMessage({ type: "progress", ...progress });
6    port.postMessage({ type: "done" });
7  });
8});

Execution context: the offscreen document. For jobs that report progress — transcoding audio, processing a batch of pages — a port carries many messages and tells either side when the other goes away. The worker connects with chrome.runtime.connect({ name: "offscreen-job" }) after the ready handshake. See streaming progress updates over a port.

5. Recover when the document disappears

 1export async function callOffscreen(msg, attempt = 0) {
 2  await ensureOffscreen();
 3  try {
 4    return await chrome.runtime.sendMessage({ target: "offscreen", ...msg });
 5  } catch (err) {
 6    if (attempt === 0 && /receiving end does not exist/i.test(err.message)) {
 7      ready = null;                                          // document closed underneath us
 8      return callOffscreen(msg, 1);
 9    }
10    throw err;
11  }
12}

Execution context: the service worker. Chrome closes audio-only documents after silence, and your own idle timer may close a document between requests. A single retry after re-ensuring the document covers that race without looping.

6. Keep the document’s surface small

The document should expose a fixed set of operations, validate inputs, and never execute strings. It runs with the extension’s origin and can reach the extension’s IndexedDB; treat it as part of your privileged surface and keep content scripts from driving it directly — route their requests through the worker’s validation.

7. Test the protocol in isolation

1// unit test: handle() is a pure function of (op, input)
2expect(await handle({ op: "parse", key: seeded })).toMatchObject({ title: "Fixture" });

Execution context: a unit test with fake-indexeddb and a DOM environment. Keeping the document’s logic in a handle function separate from messaging lets you test parsing, transcoding or clipboard formatting without creating real offscreen documents.

Common mistakes

  • Untargeted messages. The popup or options page answers instead.
  • Messaging immediately after createDocument. The listener may not exist yet.
  • Megabytes inline. Slow serialisation; hand off through IndexedDB.
  • Assuming the document persists. Re-ensure before each call.
  • Exposing generic operations. Keep a fixed, validated set.

Cross-browser variation

  • Chrome / Edge: offscreen documents with runtime messaging only; getContexts from Chrome 116 for existence checks.
  • Firefox: no offscreen API — the event-page background does the DOM work itself, so this protocol collapses into direct function calls behind a shared interface.
  • Safari: no offscreen API; use extension pages or the containing app.

Verification

  1. With the popup open, send an offscreen-targeted message and confirm only the document answers.
  2. Create the document and send a request in the same tick; confirm it waits for ready and succeeds.
  3. Process a 20 MB file via IndexedDB handoff and compare timing with inline messaging.
  4. Close the document manually and confirm the next call recreates it after one retry.

FAQ

Can the offscreen document message content scripts directly?

It has no chrome.tabs, so it cannot target a tab. Route through the worker.

Can the document call chrome.storage?

No. Only chrome.runtime is available; ask the worker, or share data through IndexedDB.

Does an open port keep the document alive?

Lifetime is governed by its reasons and your closing logic; an open port keeps the service worker alive in Chrome, which is often what matters.

Other MV3 Architecture & Extension Lifecycle Resources