Message Passing Architecture in MV3

Bridge popup, content script, and service worker contexts in Manifest V3 with one-time messages, long-lived ports, error boundaries, and reconnect patterns that survive worker termination.

The hardest problem in Manifest V3 is not an API limit — it is the communication gap between three execution contexts that never share memory. Your popup, content scripts, and background service worker each live in a separate process. When the popup closes, its state vanishes. When the worker sleeps (typically after 30 seconds of inactivity), any in-flight promise it was waiting on dies with it. Without a disciplined messaging layer, your extension silently drops data on every context boundary. This guide is part of Core APIs & Cross-Browser Data Management and covers the complete channel strategy from one-time request/response to long-lived streaming ports.

The single biggest mistake developers make is treating chrome.runtime.sendMessage like a synchronous function call. It is not. The response callback can arrive after the service worker has already been terminated, which is why every async handler must return true from onMessage.addListener to keep the channel open — and even then, the channel can close if the worker restarts before sendResponse fires. Design around that reality from day one, not as an afterthought.

MV3 message passing between popup, content script, and service workerSequence diagram showing one-time sendMessage and long-lived Port channels between the three MV3 execution contexts, with the service worker's wake/sleep cycle shown at the bottom.Popupextension page · UI threadContent Scriptisolated world · page threadService Workerbackground · non-persistentONE-TIME MESSAGE (sendMessage / onMessage)sendMessage({ type, payload })sendResponse({ result }) · return true requiredsendMessagesendResponseLONG-LIVED PORT (connect / onConnect)runtime.connect({ name: 'channel' })port.postMessage / port.onMessage (bidirectional)port.onDisconnect (worker sleep or popup close)worker sleepingwake on next messagelisteners re-register

Prerequisites checklist

Before sending a single message, confirm the following are in place:

  • manifest.json background.service_worker points to the correct entry file and type is "module" if you use ES imports.
  • Top-level listener registrationchrome.runtime.onMessage.addListener must be called synchronously at the root of the service worker script. Anything inside a Promise callback or onInstalled handler will miss early messages on cold start.
  • Decision on channel type — one-time message or long-lived port. See the detailed tradeoff guide in long-lived ports vs one-time messages.
  • Error handling for port closure — the classic "The message port closed before a response was received" error has a specific root cause; see fixing message port closed before response errors.

1. Declare the service worker in the manifest

The service worker is the hub of all background messaging. Declare it once; Chrome registers and manages its lifecycle automatically.

 1{
 2  "manifest_version": 3,
 3  "name": "Messaging Demo",
 4  "permissions": ["storage"],
 5  "background": {
 6    "service_worker": "service-worker.js",
 7    "type": "module"          // enables ES import/export in the worker
 8  },
 9  "content_scripts": [
10    {
11      "matches": ["<all_urls>"],
12      "js": ["content.js"]
13    }
14  ]
15}

Execution context: Parsed by the browser at install time. The "type": "module" flag is supported on Chrome 116+, Firefox 128+, and Safari 17+. Omit it if you target older versions and use a bundler instead.

2. One-time messages — request and response

chrome.runtime.sendMessage is the right tool when you need a single answer to a single question: fetch a config value, check an auth state, trigger a background action. The popup or content script sends a typed payload; the service worker receives it, does work, and calls sendResponse.

The critical constraint is the return true in the listener. If your handler does anything asynchronous — a chrome.storage read, a fetch, even a queued microtask — you must return true synchronously to signal Chrome that sendResponse will be called later. Without it, the channel is torn down at the end of the current tick, sendResponse becomes a no-op, and the sender receives undefined with no error thrown.

 1// service-worker.js — top-level registration
 2chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
 3  if (message.type === "GET_STATUS") {
 4    // Validate sender to prevent spoofing
 5    if (sender.id !== chrome.runtime.id) return;
 6    (async () => {
 7      const { appState } = await chrome.storage.local.get("appState");
 8      sendResponse({ ok: true, state: appState ?? null });
 9    })();
10    return true; // keep channel open until sendResponse fires
11  }
12  // Return undefined (falsy) for unhandled message types
13});

Execution context: Service worker background thread. chrome.storage is available; window, document, and localStorage are not. Firefox uses browser.runtime.onMessage and supports returning a Promise directly from the listener instead of return true — but returning true also works, making it the safest cross-browser choice.

 1// popup.js or content.js — sending side
 2async function getStatus() {
 3  try {
 4    const response = await chrome.runtime.sendMessage({ type: "GET_STATUS" });
 5    if (!response?.ok) throw new Error("Worker returned error state");
 6    return response.state;
 7  } catch (err) {
 8    // "Could not establish connection" means the worker is not yet registered
 9    console.warn("Messaging failed:", err.message);
10    return null;
11  }
12}

Execution context: Extension popup (UI thread) or content script (isolated world). Both contexts can call chrome.runtime.sendMessage without additional permissions. The call automatically wakes a sleeping service worker before delivering the message.

3. Long-lived ports — bidirectional streaming

chrome.runtime.connect opens a named Port object that both sides can write to repeatedly without a fresh handshake each time. This is the right pattern for real-time data: a live scraping feed, a progress meter, or a sidebar that must stay in sync with background processing. The tradeoff is complexity: ports disconnect whenever the service worker sleeps, and you must reconnect.

 1// popup.js — connect and handle disconnect
 2let port: chrome.runtime.Port | null = null;
 3
 4function connect() {
 5  port = chrome.runtime.connect({ name: "live-feed" });
 6
 7  port.onMessage.addListener((msg) => {
 8    if (msg.type === "FEED_ITEM") renderItem(msg.data);
 9  });
10
11  port.onDisconnect.addListener(() => {
12    port = null;
13    // Chrome fires onDisconnect when the worker goes to sleep.
14    // Re-connect on next user action or after a short delay.
15    setTimeout(connect, 2000);
16  });
17}
18
19connect();

Execution context: Extension popup page (UI thread). The Port is garbage-collected when the popup closes. If the popup reopens later, call connect() again. chrome.runtime.lastError is set if the worker was not yet ready when connect() was called — check it inside onDisconnect to distinguish a clean disconnect from an error.

 1// service-worker.js — receive the port
 2chrome.runtime.onConnect.addListener((port) => {
 3  if (port.name !== "live-feed") return;
 4
 5  const interval = setInterval(() => {
 6    port.postMessage({ type: "FEED_ITEM", data: generateItem() });
 7  }, 1000);
 8
 9  port.onDisconnect.addListener(() => {
10    clearInterval(interval);
11    // Clean up resources when popup closes or port is abandoned
12  });
13});

Execution context: Service worker background thread. Register onConnect at the top level just like onMessage. The worker stays awake as long as there is an active port connection — this is one of the few legitimate reasons to use a port rather than a one-time message.

4. Surviving worker termination

The non-persistent service worker is the single biggest source of messaging bugs in MV3. A worker that wakes on a message, starts an async operation, then sleeps before that operation completes will silently drop the result. The two strategies that reliably solve this are:

Checkpoint state to storage before yielding. If your handler reads data from an external source and then writes it somewhere, write the intermediate result to chrome.storage.local immediately after the fetch, before any further async steps. If the worker dies between steps, the next wake can resume from storage rather than restarting from scratch. This pattern is explored in detail in the chrome.storage.sync guide.

Use chrome.alarms instead of setTimeout for delayed work. setTimeout in a service worker is cleared when the worker sleeps. chrome.alarms persist across worker restarts and fire a registered onAlarm listener on the next wake, making them the correct tool for any work that must happen at a future time.

 1// service-worker.js — alarm-based deferred work
 2chrome.alarms.onAlarm.addListener((alarm) => {
 3  if (alarm.name === "retry-sync") {
 4    retryPendingSync(); // reads state from chrome.storage
 5  }
 6});
 7
 8async function scheduleRetry() {
 9  await chrome.alarms.create("retry-sync", { delayInMinutes: 1 });
10}

Execution context: Service worker. chrome.alarms requires the "alarms" permission in the manifest. The alarm fires even if the extension is updated or the browser restarts; onAlarm must therefore be registered at the top level of the worker, not inside a callback.

MV3 Constraints box

  • No persistent background page: workers terminate after ~30 s of inactivity. Design every channel to tolerate mid-flight termination.
  • sendResponse deadline: the channel is closed at the end of the synchronous listener tick unless you return true. There is no way to extend this deadline further once the tick ends.
  • Port lifetime tied to context: a Port is torn down when either end unloads (popup closes, tab navigates, worker sleeps). Reconnect logic is mandatory for long-lived channels.
  • External messaging requires externally_connectable: messages from web pages or other extensions are blocked unless that manifest key lists the allowed origins/IDs.
  • No window or document in the worker: content script DOM access must stay in the content script; pass data back via messages rather than trying to import DOM references.
  • Structured-clone only: message payloads are serialised with the structured-clone algorithm. Functions, class instances, Map, and Set will be silently dropped or throw.

Cross-browser notes

Chrome and Edge share the identical chrome.runtime namespace and behave the same for all patterns in this guide. Firefox ships MV3 support with browser.runtime as the canonical namespace; chrome.runtime is available as an alias. The key divergence: Firefox listeners that return a Promise resolve correctly without return true, which is cleaner but Chrome-incompatible if you omit return true. The safest cross-browser pattern is to always return true and always call sendResponse explicitly.

Safari’s MV3 implementation (WebKit 17.4+) enforces the strictest port limits: concurrent open ports per extension are capped, and the worker sleep timer can fire faster than on Chrome under battery-saving conditions. Test reconnect paths on Safari with the battery-saver policy enabled. For a full divergence table see the cross-browser API compatibility reference.

FeatureChrome/EdgeFirefoxSafari
chrome.runtime namespaceyesalias for browser.*yes
return true for asyncrequiredworks; Promise also validrequired
Port reconnect neededyesyesyes (aggressive sleep)
externally_connectablefull supportsame-ext ignoredpartial

What this section covers

The guides below drill into specific problems that arise when building the patterns above:

Implementing background messaging between popup and service worker walks through a complete wiring example with state hydration, retry logic, and typed payloads.

Fixing message port closed before response errors diagnoses the exact root causes of the most common runtime error in MV3 messaging and provides verified fixes.

Long-lived ports vs one-time messages lays out the decision criteria for choosing connect vs sendMessage, including reconnect-on-wake patterns and real-world tradeoffs.