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.

Published June 19, 2026 Updated June 19, 2026 8 min read
Table of Contents

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.

Decision criteria at a glance

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

One-time messages — when and how

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.

Long-lived ports — when and how

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:

  • The background needs to push data to the popup without waiting for a poll (real-time scraping results, download progress, WebSocket relay).
  • A devtools panel or side panel needs a continuous event stream from the background.
  • The popup and worker need to exchange multiple messages in a logical session with a defined start and end.
 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.

Reconnect-on-wake pattern

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.

SVG: channel lifetime comparison

Port vs sendMessage channel lifetime across worker sleep cyclesTimeline showing that sendMessage channels open and close per call while ports persist until the worker sleeps, requiring reconnect after each sleep cycle.time →sendMessagemsg 1msg 2msg 3channel opens+closeschannel opens+closeschannel opens+closesPort (connect)port open — worker aliveworker sleepsport disconnectedreconnectnew port — worker woken + alive

MV3 constraints for both patterns

  • No persistent worker: the worker sleeps after ~30 s of inactivity. sendMessage wakes it automatically; ports do not — the port disconnects and a new connect() call is required.
  • One active port per 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.
  • Structured-clone for both: all payloads are serialised. Functions, class instances, 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.

Cross-browser variation

  • Chrome / Edge: Both patterns work as documented. return true in onMessage is required for async sendResponse. Ports disconnect on worker sleep.
  • Firefox MV3: 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.
  • Safari (WebKit 17.4+): Safari’s sleep timer can fire faster than Chrome’s, especially under battery-saving modes. Ports disconnect sooner. The lazy reconnect pattern is the most reliable approach on Safari. Safari does not support browser.runtime.connect as an alias — use chrome.runtime.connect or the WebExtension polyfill.
PatternChromeFirefoxSafari
sendMessage wakes workeryesyesyes
Async sendResponse requires return trueyesoptional (Promise also works)yes
Port kept alive while connectedyesyesyes (shorter timeout)
Port disconnects on worker sleepyesyesyes (earlier)
Return Promise from listenernoyesno

FAQ

Can I use both patterns in the same extension?

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.

If a port keeps the worker alive, why not always use a port?

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.

What is the maximum number of concurrent ports?

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.

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

Other Core APIs & Cross-Browser Data Management Resources