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.
Decide when to use chrome.runtime.connect (Port) versus chrome.runtime.sendMessage in Manifest V3 — tradeoffs, reconnect-on-wake patterns, and cross-browser differences.
Manifest V3 gives you two distinct messaging primitives: chrome.runtime.sendMessage for discrete request/response exchanges, and chrome.runtime.connect for a persistent bidirectional channel. Choosing the wrong one at design time is painful to fix later — a long-lived port adds reconnect complexity you do not need for a single config fetch, and a one-time message cannot efficiently stream a series of updates without re-establishing the channel on every item. This guide is part of the message passing architecture overview and gives you the exact criteria for each choice, with real-world reconnect patterns for ports.
The critical constraint that makes this decision non-trivial in MV3: the background service worker terminates after roughly 30 seconds of inactivity, and it tears down every open Port connection when it sleeps. A long-lived port is therefore not actually long-lived across worker sleep cycles — you must reconnect. If your use case does not need ongoing bidirectional communication within a single worker-alive window, sendMessage is almost always the right answer.
Use sendMessage when: Use connect (Port) when:
───────────────────────────────────── ──────────────────────────────────────
Single question → single answer Repeated messages in both directions
No need to push updates from background Background needs to push without a poll
Fire-and-forget (no response needed) Streaming: progress, live data, events
Handler completes in < 30 s Session needs coordinated teardown
Simplicity is the priority You can own the reconnect logic
chrome.runtime.sendMessage is the right tool for the majority of popup-to-worker and content-script-to-worker interactions. The popup asks a question; the service worker answers; the channel closes. There is no state to manage, no teardown to handle, and the worker wake-up is implicit — Chrome wakes it before delivering the message.
1// popup.ts — one-time request, typed response
2interface ConfigResponse {
3 theme: "light" | "dark";
4 featureFlags: Record<string, boolean>;
5}
6
7async function loadConfig(): Promise<ConfigResponse> {
8 const response = await chrome.runtime.sendMessage<
9 { type: "GET_CONFIG" },
10 ConfigResponse
11 >({ type: "GET_CONFIG" });
12
13 if (!response) {
14 throw new Error("No response from service worker");
15 }
16 return response;
17}
Execution context: Extension popup page (UI thread). The sendMessage call automatically wakes the service worker if it is sleeping. The worker cold-starts, re-registers its listeners, and delivers the response before the popup’s await resolves. Round-trip latency on a cold start is typically 50–200 ms.
Use one-time messages for: authentication checks, config reads, triggering a one-off background action (send a notification, update the badge), and querying data that lives in chrome.storage. The simplicity advantage is decisive for these patterns.
chrome.runtime.connect creates a named Port object that both sides can write to and listen on with postMessage and onMessage. The port stays open as long as both ends are alive. This makes it the right choice when:
1// popup.ts — connect and receive a stream
2let port: chrome.runtime.Port | null = null;
3
4function openFeed() {
5 port = chrome.runtime.connect({ name: "progress-feed" });
6
7 port.onMessage.addListener((msg: { type: string; pct: number }) => {
8 if (msg.type === "PROGRESS") {
9 updateProgressBar(msg.pct);
10 }
11 if (msg.type === "DONE") {
12 showCompletion();
13 port?.disconnect();
14 port = null;
15 }
16 });
17
18 port.onDisconnect.addListener(() => {
19 // Fires when worker sleeps, extension reloads, or explicit disconnect()
20 port = null;
21 showDisconnectedState();
22 });
23
24 // Tell the worker to start
25 port.postMessage({ type: "START_TASK", taskId: "export-42" });
26}
Execution context: Extension popup page. The popup’s Port is automatically disconnected when the popup window closes (the user navigates away or closes the popup). Always check chrome.runtime.lastError inside onDisconnect to distinguish a worker sleep from a true error.
1// service-worker.js — receive the port and stream back
2chrome.runtime.onConnect.addListener((port) => {
3 if (port.name !== "progress-feed") return;
4
5 port.onMessage.addListener(async (msg) => {
6 if (msg.type !== "START_TASK") return;
7
8 const taskId: string = msg.taskId;
9 try {
10 for await (const progress of runExportTask(taskId)) {
11 port.postMessage({ type: "PROGRESS", pct: progress });
12 }
13 port.postMessage({ type: "DONE" });
14 } catch (err) {
15 port.postMessage({ type: "ERROR", message: (err as Error).message });
16 }
17 });
18
19 port.onDisconnect.addListener(() => {
20 // Popup closed mid-task — cancel the task
21 cancelTask(port.name);
22 });
23});
Execution context: Service worker. Registering onConnect at the top level keeps the worker alive as long as the port is open — an open port is one of Chrome’s valid keep-alive signals. The worker will not sleep while the port is connected. This is a significant difference from sendMessage, where the worker can sleep between calls.
When the popup outlives a worker sleep — for example, a sidebar that stays open while the user browses — the port will disconnect when the worker sleeps and the popup must reconnect. The naive reconnect on every onDisconnect leads to a rapid reconnect storm; the correct pattern reconnects lazily, on the next user action or message send.
1// sidebar.ts — lazy reconnect pattern
2let port: chrome.runtime.Port | null = null;
3
4function getPort(): chrome.runtime.Port {
5 if (port) return port;
6
7 port = chrome.runtime.connect({ name: "sidebar-channel" });
8
9 port.onMessage.addListener(handleMessage);
10
11 port.onDisconnect.addListener(() => {
12 port = null; // nullify — next getPort() call reconnects
13 const err = chrome.runtime.lastError; // consume to suppress console noise
14 if (err && !err.message?.includes("disconnected")) {
15 console.warn("Port error:", err.message);
16 }
17 });
18
19 return port;
20}
21
22// Call getPort() instead of port directly everywhere
23function sendToWorker(payload: Record<string, unknown>) {
24 getPort().postMessage(payload);
25}
Execution context: Side panel page or devtools panel (persistent UI context). The lazy pattern avoids sending messages to a stale port and avoids creating multiple overlapping connections. Every postMessage call first ensures a live port exists, creating one if needed. The first postMessage after reconnect also wakes the sleeping worker.
sendMessage wakes it automatically; ports do not — the port disconnects and a new connect() call is required.connect() call: each call to connect() creates a new, independent Port. Do not call connect() in a loop without disconnecting previous ports.externally_connectable required for cross-extension ports: ports between different extensions or from web pages require explicit manifest configuration. sendMessage from the same extension does not.Error objects (beyond the message string), Map, and Set will not survive the clone.sendMessage reply limit: sendResponse can only be called once per message. For more than one reply, switch to a port.return true in onMessage is required for async sendResponse. Ports disconnect on worker sleep.browser.runtime.sendMessage can return a Promise from the listener instead of using sendResponse + return true. browser.runtime.connect works identically. Firefox’s MV3 service worker sleep timer is configurable in about:config and may differ from Chrome’s.browser.runtime.connect as an alias — use chrome.runtime.connect or the WebExtension polyfill.| Pattern | Chrome | Firefox | Safari |
|---|---|---|---|
sendMessage wakes worker | yes | yes | yes |
Async sendResponse requires return true | yes | optional (Promise also works) | yes |
| Port kept alive while connected | yes | yes | yes (shorter timeout) |
| Port disconnects on worker sleep | yes | yes | yes (earlier) |
| Return Promise from listener | no | yes | no |
Yes, and it is common to do so. Use sendMessage for config reads, auth checks, and single-action triggers from the popup. Use connect for the devtools panel, a side panel, or any context that needs the worker to push updates unprompted.
Because the port only keeps the worker alive while the popup is open. If the popup closes (even for a second), the port disconnects, the worker can sleep, and your “persistent” connection is gone. For work that must continue while the popup is closed, use chrome.alarms or chrome.offscreen documents — not an open port.
Chrome does not document a hard limit, but practical testing shows degradation beyond 10–15 concurrent ports per extension. Safari enforces a stricter cap. Use one port per logical channel (one for devtools, one for sidebar) rather than one per tab.
port.postMessage throw if the port is disconnected?Yes — calling postMessage on a disconnected port throws a synchronous runtime error ("Attempting to use a disconnected port object"). Wrap calls in a try/catch or check port !== null before calling postMessage, especially after a worker wake event.
Chrome vs Firefox vs Safari Manifest V3 API compatibility reference — namespace differences, Promise vs callback, storage quotas, declarativeNetRequest, and the webextension-polyfill.
Use chrome.scripting in Manifest V3 to dynamically inject JavaScript and CSS — executeScript, registerContentScripts, world isolation, and activeTab patterns.
Persist and synchronise extension state across devices with chrome.storage.sync — quotas, async patterns, change events, encryption and cross-browser adapters for Manifest V3.
Master chrome.declarativeNetRequest static, dynamic, and session rulesets in MV3 — rule schema, modifyHeaders, redirect, block actions, quota limits, and getMatchedRules debugging.