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 registration — chrome.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 lifetime of a single sendMessage round tripThe popup sends one message, the worker wakes, the listener returns true to hold the channel open, and sendResponse closes it.PopupMessage channelService workerruntime.sendMessage(msg)wake worker, run onMessagecold start if evictedreturn true — k…await storage /…sendResponse(result)promise resolveschannel closes here
The channel closes the instant the listener returns — unless it returned true first.

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:

What a message costs depending on when it arrivesTimeline of a worker's life showing the cheap warm-path message and the expensive message that has to pay a cold start.worker startslong after evictionCold startparse + register listen…Warm — handlers cheapidle timer runningEvictedheap freed, listeners gonetop-level addListener calls must already have runmessage answered in under a millisecondnext message pays the cold start again
The same sendMessage call costs a fraction of a millisecond warm and a full cold start once the worker has been evicted.

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.

5. Designing the message protocol

Message passing in an extension is an internal API between contexts that are written separately, loaded separately, and — during an update — can run different versions of your code at the same time. Treating it as an API, with a defined protocol, avoids most of the bugs that make messaging feel unreliable.

Name messages by domain and verb. articles:save, settings:read, sync:now — a namespaced type makes handlers easy to find, logs easy to read, and collisions between features impossible. A single type field on every message is the only thing the routing layer needs to know.

Keep handlers narrow. One handler answers one question or performs one action. A handler that returns “everything the popup needs” couples the worker to one screen’s layout and fails as a unit; three narrow handlers compose, cache and fail independently.

Make errors travel. An exception thrown in a handler does not cross the boundary on its own; the sender sees undefined. Serialise errors into the reply and rethrow them on the sending side, so a failure in the worker surfaces as a failure in the popup — the pattern in wrapping message passing in promises.

Version for skew. During an update, an old content script may send to a new worker. Include a protocol version in each message and accept the current and previous versions, so the skew window degrades gracefully instead of producing puzzling failures.

6. Choosing the transport per interaction

One-time messages, long-lived ports and storage change events are three different transports, and each interaction has a natural fit. A request with a single answer — read a setting, save an item — is a one-time message. A stream of updates the sender wants while it is open — progress of an export, live results from a DevTools panel — is a port, which also keeps the worker alive while connected. State that several surfaces render — counts, status, configuration — is best shared through chrome.storage and onChanged, with no message at all: every surface reads the same keys and updates when they change, whether or not the worker is running.

Choosing storage for shared state is the decision that removes the most bugs. It means the popup renders correctly when the worker is asleep, a side panel stays in sync without a subscription protocol, and nothing is lost when a context closes mid-conversation. Messages are then reserved for what they are good at: asking another context to do something, and getting an answer back. The comparison is developed in long-lived ports vs one-time messages.

7. Failure is normal, so design for it

In most software, a failed call between components is exceptional. In an extension it is routine: the tab you messaged has no content script because it is a restricted page, the content script was orphaned by an update, the popup closed before the reply arrived, the worker was evicted between two messages of a conversation. Code that treats these as errors produces noisy logs and confusing behaviour; code that treats them as expected outcomes is calm and correct.

The practical rule is to classify every messaging failure at the point it is caught. “No receiving end” when messaging a tab means the tab has nothing to talk to — return null and move on. A timeout on a request to the worker means it was starting or stopping — retry once for idempotent reads, as described in messages sent while the worker is starting. An “Extension context invalidated” error in a content script means the extension updated underneath it — stop and do nothing further. Only failures outside these known categories are genuine errors worth reporting, and separating them this way keeps error reporting focused on real bugs.

MV3 constraints to design around

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

Further guides in this topic

The guides below go deeper into specific message passing architecture in mv3 problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.

  • Messages Sent While the Worker Is Starting — Diagnose and fix the MV3 cold-start race where a popup or content script message arrives before the service worker has registered its listeners — queueing, readiness and retry.
  • Wrapping Message Passing in Promises — Build a typed request/response layer over chrome.runtime.sendMessage — timeouts, error propagation across contexts, and a handler registry that never leaves a channel open.
  • Broadcasting Messages to All Tabs — Send one message to every content script in MV3 without unhandled promise rejections: enumerating tabs, tolerating frames with no listener, batching, and using storage.onChanged as a broadcast instead.
  • Cross-Extension and Native Messaging — Talk to another extension or a native host from MV3: externally_connectable, sendMessage with an extension id, native messaging host manifests, and validating every inbound sender.
  • Handling Errors Across Message Boundaries — Propagate errors through chrome.runtime messaging in MV3: why thrown errors vanish, an ok/error envelope, stable error codes, distinguishing no receiver and invalidated context, timeouts, and logging with context.
  • Messaging Between Frames in the Same Tab — Coordinate content scripts in a page and its iframes in MV3: all_frames injection, frameId targeting with tabs.sendMessage, relaying through the service worker, webNavigation.getAllFrames and postMessage pitfalls.
  • Passing Structured Data and Transferables — What survives chrome.runtime.sendMessage in MV3: the structured clone rules, why Map and Date behave differently from class instances, how to move binary data, and why transferables do not transfer.
  • Streaming Progress Updates over a Port — Report progress from a long MV3 service worker job to a popup or side panel with runtime.connect: port lifecycle, throttled progress messages, cancellation on disconnect, reconnecting and resuming from stored state.
  • Typed Message Contracts with TypeScript — Type every chrome.runtime message in an MV3 extension: a discriminated union of requests, a response map, typed send and handler helpers, runtime validation at the boundary, and versioning without breakage.

Handling Errors Across Message Boundaries

Propagate errors through chrome.runtime messaging in MV3: why thrown errors vanish, an ok/error envelope, stable error codes, distinguishing no receiver and invalidated context, timeouts, and logging with context.

Messaging Between Frames in the Same Tab

Coordinate content scripts in a page and its iframes in MV3: all_frames injection, frameId targeting with tabs.sendMessage, relaying through the service worker, webNavigation.getAllFrames and postMessage pitfalls.

Streaming Progress Updates over a Port

Report progress from a long MV3 service worker job to a popup or side panel with runtime.connect: port lifecycle, throttled progress messages, cancellation on disconnect, reconnecting and resuming from stored state.

Typed Message Contracts with TypeScript

Type every chrome.runtime message in an MV3 extension: a discriminated union of requests, a response map, typed send and handler helpers, runtime validation at the boundary, and versioning without breakage.

Messages Sent While the Worker Is Starting

Diagnose and fix the MV3 cold-start race where a popup or content script message arrives before the service worker has registered its listeners — queueing, readiness and retry.

Wrapping Message Passing in Promises

Build a typed request/response layer over chrome.runtime.sendMessage — timeouts, error propagation across contexts, and a handler registry that never leaves a channel open.

Broadcasting Messages to All Tabs

Send one message to every content script in MV3 without unhandled promise rejections: enumerating tabs, tolerating frames with no listener, batching, and using storage.onChanged as a broadcast instead.

Cross-Extension and Native Messaging

Talk to another extension or a native host from MV3: externally_connectable, sendMessage with an extension id, native messaging host manifests, and validating every inbound sender.

Passing Structured Data and Transferables

What survives chrome.runtime.sendMessage in MV3: the structured clone rules, why Map and Date behave differently from class instances, how to move binary data, and why transferables do not transfer.

Fixing 'Message Port Closed Before a Response Was Received' Errors

Diagnose and fix the 'The message port closed before a response was received' error in MV3 extensions — root causes, step-by-step repair, and verification via DevTools.

Long-Lived Ports vs One-Time Messages in MV3

Decide when to use chrome.runtime.connect (Port) versus chrome.runtime.sendMessage in Manifest V3 — tradeoffs, reconnect-on-wake patterns, and cross-browser differences.

Implementing Background Messaging Between Popup and Service Worker

Wire up chrome.runtime.sendMessage between an MV3 popup and service worker with typed payloads, async response handling, state hydration on worker wake, and cross-browser compatibility.