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.
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.
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.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."The message port closed before a response was received" error has a specific root cause; see fixing message port closed before response errors.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.
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.
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.
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.
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 is torn down when either end unloads (popup closes, tab navigates, worker sleeps). Reconnect logic is mandatory for long-lived channels.externally_connectable: messages from web pages or other extensions are blocked unless that manifest key lists the allowed origins/IDs.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.Map, and Set will be silently dropped or throw.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.
| Feature | Chrome/Edge | Firefox | Safari |
|---|---|---|---|
chrome.runtime namespace | yes | alias for browser.* | yes |
return true for async | required | works; Promise also valid | required |
| Port reconnect needed | yes | yes | yes (aggressive sleep) |
externally_connectable | full support | same-ext ignored | partial |
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.
connect instead of sendMessage.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.
Decide when to use chrome.runtime.connect (Port) versus chrome.runtime.sendMessage in Manifest V3 — tradeoffs, reconnect-on-wake patterns, and cross-browser differences.
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.