Development18 min read

Message Passing in Chrome Extensions Explained

Complete guide to message passing in Chrome extensions. Covers one-time messages, long-lived connections, external messaging, and type-safe communication patterns.

C
CWS Kit Team
Share

Chrome extensions are not monolithic programs. They are a collection of isolated contexts — a service worker running in the background, content scripts injected into web pages, popups that appear and disappear, side panels, options pages, and sometimes even external web pages that need to talk to the extension. None of these contexts share memory. The service worker cannot read a variable from the content script. The popup cannot call a function in the background directly. Every piece of communication flows through Chrome's message passing APIs.

Getting message passing right is the difference between an extension that works reliably and one that silently drops data, leaks memory through abandoned ports, or crashes when users navigate between pages. This guide covers every messaging pattern available in Manifest V3: one-time messages with runtime.sendMessage and tabs.sendMessage, long-lived connections with runtime.connect, external messaging from web pages, type-safe message schemas, error handling, and the specific gotchas that have burned every extension developer at least once.

2

One-Time APIs

runtime.sendMessage and tabs.sendMessage

2

Long-Lived APIs

runtime.connect and tabs.connect

64 MB

Max Message Size

JSON-serializable data per message

Yes

External Messaging

Web pages can message extensions via externally_connectable

One-Time Messages: The Foundation#

The simplest messaging pattern is the one-time request/response. You send a message, optionally receive a single response, and the channel closes. This is perfect for discrete operations: "get the current user," "save this setting," "run this analysis and return the result."

Content Script to Service Worker#

Content scripts use chrome.runtime.sendMessage to talk to the service worker. The service worker listens with chrome.runtime.onMessage:

// content-script.ts — send page data to the service worker
interface ExtractDataMessage {
  type: "EXTRACT_DATA";
  payload: {
    url: string;
    title: string;
    wordCount: number;
  };
}
 
interface ExtractDataResponse {
  saved: boolean;
  id: string;
}
 
async function reportPageData(): Promise<void> {
  const message: ExtractDataMessage = {
    type: "EXTRACT_DATA",
    payload: {
      url: window.location.href,
      title: document.title,
      wordCount: document.body.innerText.split(/\s+/).length,
    },
  };
 
  const response = await chrome.runtime.sendMessage<
    ExtractDataMessage,
    ExtractDataResponse
  >(message);
 
  if (response?.saved) {
    console.log(`Data saved with id: ${response.id}`);
  }
}
// background.ts — handle the message in the service worker
chrome.runtime.onMessage.addListener(
  (
    message: ExtractDataMessage,
    sender: chrome.runtime.MessageSender,
    sendResponse: (response: ExtractDataResponse) => void
  ) => {
    if (message.type === "EXTRACT_DATA") {
      // sender.tab contains info about which tab sent this
      const tabId = sender.tab?.id;
 
      saveToStorage(message.payload)
        .then((id) => {
          sendResponse({ saved: true, id });
        })
        .catch((err) => {
          console.error("Save failed:", err);
          sendResponse({ saved: false, id: "" });
        });
 
      // CRITICAL: return true to keep the message channel open for async response
      return true;
    }
  }
);

Service Worker to Content Script#

Communication in the other direction — from the service worker to a content script — requires chrome.tabs.sendMessage, because you need to specify which tab to target:

// background.ts — send a message to a specific tab's content script
async function notifyContentScript(
  tabId: number,
  theme: "light" | "dark"
): Promise<void> {
  try {
    const response = await chrome.tabs.sendMessage(tabId, {
      type: "THEME_CHANGED",
      payload: { theme },
    });
    console.log("Content script acknowledged:", response);
  } catch (err) {
    // This fails if no content script is listening in that tab.
    // Common when the tab has a chrome:// URL or the content script
    // hasn't been injected yet.
    console.warn(`Tab ${tabId} not reachable:`, err);
  }
}

The error handling here is not optional. tabs.sendMessage throws if no listener exists in the target tab. This happens when the tab is a browser internal page (chrome://, about:), when the content script hasn't loaded yet, or when the tab was recently navigated and the old content script was torn down before the new one loaded. Always wrap tabs.sendMessage in a try/catch.

Popups communicate with the service worker using the same runtime.sendMessage API that content scripts use. The difference is architectural, not API-level — the popup is an extension page, so it shares the same extension origin as the service worker. This means the popup can also directly call Chrome extension APIs (like chrome.storage) without messaging at all. Use messaging when the operation should be centralized in the service worker — for instance, when you need to coordinate state across multiple tabs or perform network requests that should survive the popup closing.

// popup.ts — request data from the service worker
document.getElementById("analyze-btn")?.addEventListener("click", async () => {
  const [tab] = await chrome.tabs.query({
    active: true,
    currentWindow: true,
  });
 
  if (!tab?.id) return;
 
  // Ask the service worker to coordinate the analysis
  const result = await chrome.runtime.sendMessage({
    type: "RUN_ANALYSIS",
    payload: { tabId: tab.id },
  });
 
  renderResults(result);
});
Do
  • Use runtime.sendMessage from content scripts and popups to reach the service worker
  • Use tabs.sendMessage from the service worker to reach a specific content script
  • Return true from onMessage listeners when the response is asynchronous
  • Wrap tabs.sendMessage in try/catch — it throws if no listener exists
  • Include a type field in every message for routing
Avoid
  • Use tabs.sendMessage from a content script — it has no access to the tabs API
  • Forget to return true for async responses — sendResponse silently becomes a no-op
  • Assume a content script is listening in every tab — restricted pages have no content scripts
  • Send non-serializable data (functions, DOM nodes, class instances) — messages must be JSON-compatible
  • Use one-time messages for high-frequency streaming data — use ports instead

Long-Lived Connections with Ports#

One-time messages work well for request/response patterns, but they fall apart when you need a persistent channel. If your content script streams DOM mutations to the service worker, or your popup displays real-time progress updates from a long-running background task, opening and closing a new message channel for every event is wasteful and unreliable. Long-lived connections solve this.

You create a port with chrome.runtime.connect (from content scripts or popups) or chrome.tabs.connect (from the service worker to a content script). The port stays open until either side disconnects or the context is destroyed (tab closed, popup hidden, service worker terminated).

// content-script.ts — open a persistent connection to the service worker
interface MutationEvent {
  type: "DOM_MUTATION";
  payload: {
    added: number;
    removed: number;
    timestamp: number;
  };
}
 
const port = chrome.runtime.connect({ name: "mutation-stream" });
 
const observer = new MutationObserver((mutations) => {
  let added = 0;
  let removed = 0;
 
  for (const mutation of mutations) {
    added += mutation.addedNodes.length;
    removed += mutation.removedNodes.length;
  }
 
  const event: MutationEvent = {
    type: "DOM_MUTATION",
    payload: { added, removed, timestamp: Date.now() },
  };
 
  try {
    port.postMessage(event);
  } catch {
    // Port was disconnected — stop observing
    observer.disconnect();
  }
});
 
observer.observe(document.body, { childList: true, subtree: true });
 
port.onDisconnect.addListener(() => {
  observer.disconnect();
  console.log("Port disconnected, stopped observing");
});
// background.ts — accept the connection and process the stream
chrome.runtime.onConnect.addListener((port) => {
  if (port.name !== "mutation-stream") return;
 
  const tabId = port.sender?.tab?.id;
  console.log(`Mutation stream opened from tab ${tabId}`);
 
  let mutationCount = 0;
 
  port.onMessage.addListener((message: MutationEvent) => {
    if (message.type === "DOM_MUTATION") {
      mutationCount += message.payload.added + message.payload.removed;
      // Batch processing, update badge, etc.
      if (tabId) {
        chrome.action.setBadgeText({
          text: String(mutationCount),
          tabId,
        });
      }
    }
  });
 
  port.onDisconnect.addListener(() => {
    console.log(`Mutation stream closed from tab ${tabId}`);
    // Clean up any resources associated with this connection
  });
});

Port Disconnect: The Silent Killer#

Port disconnection happens in more scenarios than you might expect. When the user navigates to a new page, the content script is destroyed and all its ports disconnect. When the popup closes, its ports disconnect. When the service worker is terminated due to inactivity (after about 30 seconds of idle time in MV3), all ports on the service worker side disconnect. And here is the brutal part: port.postMessage after disconnect throws an error. If you do not handle onDisconnect and guard your postMessage calls, your extension will throw uncaught exceptions.

Always register an onDisconnect handler. Always wrap postMessage in a try/catch or check a flag that you set in the disconnect handler. For content scripts that need to survive navigations within a single-page application, see our guide on content script patterns and pitfalls.

When to Use Ports vs. One-Time Messages#

The decision is straightforward. Use one-time messages (sendMessage) when: you need a single request with a single response, the interaction is triggered by a user action or a discrete event, and the content of each message is independent. Use ports (connect) when: you need to send multiple messages over time on the same channel, you want bidirectional streaming, you need to detect when the other side goes away, or you need to maintain conversational state between messages without re-establishing context each time.

Most extensions use one-time messages for 90% of their communication and ports for the remaining 10% — things like real-time search-as-you-type in a popup that queries the service worker repeatedly, live previews that stream DOM changes, or download progress reporting.

External Messaging: Web Pages to Extensions#

Sometimes a web page needs to talk to your extension. A SaaS dashboard might want to trigger extension features, a companion website might need to authenticate the user through the extension, or a developer tool might need to exchange data with a page-level debugger. Chrome supports this through external messaging.

First, declare which origins are allowed to message your extension in the manifest:

{
  "externally_connectable": {
    "matches": ["https://yourdomain.com/*", "https://*.yourdomain.com/*"]
  }
}

Then the web page can send messages using your extension's ID:

// On your web page (not extension code)
const EXTENSION_ID = "abcdefghijklmnopqrstuvwxyz123456";
 
async function sendToExtension(data: unknown): Promise<unknown> {
  return new Promise((resolve, reject) => {
    chrome.runtime.sendMessage(
      EXTENSION_ID,
      { type: "WEB_PAGE_REQUEST", payload: data },
      (response) => {
        if (chrome.runtime.lastError) {
          reject(new Error(chrome.runtime.lastError.message));
        } else {
          resolve(response);
        }
      }
    );
  });
}

In the service worker, handle these messages with runtime.onMessageExternal:

// background.ts — handle messages from external web pages
chrome.runtime.onMessageExternal.addListener(
  (message, sender, sendResponse) => {
    // Validate the sender origin
    const allowedOrigins = [
      "https://yourdomain.com",
      "https://app.yourdomain.com",
    ];
 
    if (!sender.origin || !allowedOrigins.includes(sender.origin)) {
      sendResponse({ error: "Unauthorized origin" });
      return;
    }
 
    if (message.type === "WEB_PAGE_REQUEST") {
      handleWebPageRequest(message.payload)
        .then((result) => sendResponse({ success: true, data: result }))
        .catch((err) => sendResponse({ success: false, error: err.message }));
      return true; // async response
    }
  }
);

External messaging also supports long-lived connections via runtime.onConnectExternal, following the same port pattern described above. Always validate the sender origin — without that check, any website matching your externally_connectable pattern can send arbitrary messages to your extension.

Type-Safe Message Schemas with TypeScript#

As your extension grows, the number of distinct message types multiplies. Without structure, you end up with a sprawling onMessage listener full of string comparisons and any types, where a typo in a message type silently drops the message and nobody knows why. TypeScript discriminated unions solve this comprehensively.

Define all your message types in a single shared file:

// messages.ts — the single source of truth for all message types
 
// Each message type is a tagged union member
interface ExtractDataMessage {
  type: "EXTRACT_DATA";
  payload: { url: string; title: string; wordCount: number };
}
 
interface ExtractDataResponse {
  saved: boolean;
  id: string;
}
 
interface ThemeChangedMessage {
  type: "THEME_CHANGED";
  payload: { theme: "light" | "dark" };
}
 
interface ThemeChangedResponse {
  applied: boolean;
}
 
interface RunAnalysisMessage {
  type: "RUN_ANALYSIS";
  payload: { tabId: number };
}
 
interface RunAnalysisResponse {
  score: number;
  issues: string[];
}
 
interface GetStatusMessage {
  type: "GET_STATUS";
}
 
interface GetStatusResponse {
  activeConnections: number;
  uptime: number;
}
 
// Discriminated union of all messages
type ExtensionMessage =
  | ExtractDataMessage
  | ThemeChangedMessage
  | RunAnalysisMessage
  | GetStatusMessage;
 
// Map message types to their response types
type ResponseMap = {
  EXTRACT_DATA: ExtractDataResponse;
  THEME_CHANGED: ThemeChangedResponse;
  RUN_ANALYSIS: RunAnalysisResponse;
  GET_STATUS: GetStatusResponse;
};
 
// Type-safe send function
async function sendTypedMessage<T extends ExtensionMessage>(
  message: T
): Promise<ResponseMap[T["type"]]> {
  return chrome.runtime.sendMessage(message);
}

Now the service worker handler gets full type narrowing:

// background.ts — type-safe message router
chrome.runtime.onMessage.addListener(
  (
    message: ExtensionMessage,
    sender: chrome.runtime.MessageSender,
    sendResponse: (response: unknown) => void
  ) => {
    switch (message.type) {
      case "EXTRACT_DATA":
        // TypeScript knows message.payload is { url, title, wordCount }
        handleExtractData(message.payload, sender).then(sendResponse);
        return true;
 
      case "THEME_CHANGED":
        // TypeScript knows message.payload is { theme }
        applyTheme(message.payload.theme).then(sendResponse);
        return true;
 
      case "RUN_ANALYSIS":
        // TypeScript knows message.payload is { tabId }
        runAnalysis(message.payload.tabId).then(sendResponse);
        return true;
 
      case "GET_STATUS":
        // TypeScript knows there is no payload
        sendResponse({
          activeConnections: getConnectionCount(),
          uptime: performance.now(),
        });
        return false; // synchronous response
 
      default:
        // Exhaustiveness check — if you add a new message type but forget
        // to handle it, TypeScript will error here at compile time
        const _exhaustive: never = message;
        return false;
    }
  }
);

The never exhaustiveness check at the bottom is the key safety net. When you add a new member to the ExtensionMessage union, TypeScript will immediately flag every switch statement that does not handle it. This eliminates the class of bugs where a new feature's messages are sent but never received because nobody added a handler.

For a deeper look at how to structure data flow across these contexts using storage as a shared state layer, see our Chrome Storage API complete guide.

Error Handling Patterns#

Message passing errors in Chrome extensions are subtle. Messages can fail silently, errors appear in unexpected places, and the debugging experience ranges from mediocre to nonexistent. Here are the patterns that actually hold up in production.

The Unhandled Message Problem#

When runtime.sendMessage is called and no listener handles it (no listener returns true or calls sendResponse), Chrome sets chrome.runtime.lastError with the message "Could not establish connection. Receiving end does not exist." In the promise-based API, this rejects the promise. If you do not catch it, you get an uncaught promise rejection that shows up in error tracking.

This happens legitimately when: the service worker was terminated and has not restarted yet, the content script was not injected in the target tab, or the extension was just updated and the old content scripts are orphaned (they still run but can no longer communicate with the new service worker).

Defensive Messaging Wrapper#

Build a wrapper that handles these edge cases:

// safe-message.ts — production-grade message sender
async function safeSendMessage<T extends ExtensionMessage>(
  message: T
): Promise<ResponseMap[T["type"]] | null> {
  try {
    const response = await chrome.runtime.sendMessage(message);
    return response ?? null;
  } catch (err) {
    const errorMessage =
      err instanceof Error ? err.message : String(err);
 
    if (errorMessage.includes("Receiving end does not exist")) {
      // Expected when service worker is not running or was just restarted
      console.warn("Service worker not available, message dropped:", message.type);
      return null;
    }
 
    if (errorMessage.includes("Extension context invalidated")) {
      // Extension was updated — this content script is orphaned
      console.warn("Extension context invalidated, stopping.");
      return null;
    }
 
    // Unexpected error — rethrow for error tracking
    throw err;
  }
}
 
async function safeSendToTab<T>(
  tabId: number,
  message: T
): Promise<unknown | null> {
  try {
    return await chrome.tabs.sendMessage(tabId, message);
  } catch {
    // Tab may not have a content script or may be restricted
    return null;
  }
}

Checklist

  • Every sendMessage call is wrapped in try/catch or has a .catch() handler
  • onMessage listeners return true when calling sendResponse asynchronously
  • Port-based code handles onDisconnect and guards postMessage against closed ports
  • External messages validate sender.origin before processing
  • Message types use a discriminated union with exhaustiveness checking
  • Content scripts handle the 'Extension context invalidated' error gracefully
  • Service worker reconnection logic exists for long-lived port connections
  • Orphaned content scripts (post-update) detect and stop their own messaging

Common Gotchas and How to Fix Them#

Gotcha 1: Forgetting return true for Async Responses#

This bears repeating because it causes more debugging hours than any other messaging issue. If your onMessage handler does any asynchronous work — a fetch, a storage read, a setTimeout, anything — before calling sendResponse, you must return true from the outer listener function. Not from inside the .then(). Not from inside the async callback. From the synchronous execution path of the listener itself.

A common trap is writing the listener as an async function:

// BROKEN — async functions always return a Promise, not true
chrome.runtime.onMessage.addListener(async (message, sender, sendResponse) => {
  const data = await chrome.storage.local.get("key");
  sendResponse(data); // too late — channel already closed
});
 
// FIXED — use a non-async wrapper that returns true
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "GET_DATA") {
    chrome.storage.local.get("key").then((data) => {
      sendResponse(data);
    });
    return true; // keeps channel open
  }
});

The async listener pattern is tempting because it looks clean, but it fundamentally does not work with Chrome's onMessage API. The return value of an async function is always a Promise, which Chrome interprets as a falsy non-true value, so it closes the channel immediately. Stick with the non-async pattern and call .then() on your promises.

Gotcha 2: Orphaned Content Scripts After Extension Update#

When you publish a new version of your extension, Chrome installs it immediately (or soon after). The new service worker starts, but content scripts from the old version are still running in open tabs. These orphaned scripts can still execute code and make DOM changes, but their chrome.runtime.sendMessage calls will fail with "Extension context invalidated." If you do not handle this, users see errors until they refresh the page.

The fix is detection and graceful shutdown:

// content-script.ts — detect orphan state and clean up
function isExtensionContextValid(): boolean {
  try {
    // This throws if the context is invalidated
    void chrome.runtime.id;
    return true;
  } catch {
    return false;
  }
}
 
// Check before any messaging
async function sendIfAlive(message: ExtensionMessage): Promise<unknown | null> {
  if (!isExtensionContextValid()) {
    teardownContentScript();
    return null;
  }
  return safeSendMessage(message);
}
 
function teardownContentScript(): void {
  // Remove DOM modifications, disconnect observers, clean up event listeners
  observer?.disconnect();
  injectedUI?.remove();
  console.log("Content script orphaned — cleaned up and stopped.");
}

Gotcha 3: Message Ordering Is Not Guaranteed#

If you send two messages in quick succession, Chrome does not guarantee they arrive in the same order. For one-time messages this rarely matters because each is independent. For ports, messages sent through a single port are ordered, but messages sent through different ports or through a mix of sendMessage and postMessage are not. If ordering matters, add a sequence number to your messages and re-order on the receiving side, or use a single port for all ordered communication.

Gotcha 4: Popup Lifecycle and Messaging#

The popup exists only while it is visible. The moment the user clicks outside the popup or switches tabs, the popup is destroyed. Any in-flight sendMessage calls from the popup will never receive their response. Any ports opened by the popup will disconnect. Design your extension so that the popup is a thin view layer: it requests data on open, displays it, sends user actions to the service worker, and does not care about receiving responses to those actions after it closes. If you need the service worker to update the popup with new data while it is open, use a port — and handle the disconnect gracefully when the popup closes.

Gotcha 5: Messaging Limits and Performance#

Each message must be JSON-serializable. You cannot send functions, DOM nodes, Map, Set, ArrayBuffer (use a regular array of numbers instead), or class instances (they arrive as plain objects, losing their prototype). The maximum message size is 64 MB, but sending large payloads through messaging is slow because Chrome serializes and deserializes the data. For large data transfers, write the data to chrome.storage.session and send a small message with a reference key instead.

For a solid understanding of how the service worker lifecycle affects all of these patterns, see our deep dive on background service workers.

Putting It All Together: A Real Architecture#

Here is how message passing typically fits into a production extension. The service worker is the central hub. Content scripts report data up and receive commands down through one-time messages for discrete operations and ports for streaming. The popup reads cached state from storage on open and sends user actions as one-time messages. External web pages use onMessageExternal for cross-origin communication. Every message follows a typed schema. Every sender handles errors. Every port handles disconnection.

Checklist

  • Define all message types in a shared messages.ts file with discriminated unions
  • Route messages through a single onMessage listener with a switch statement
  • Use one-time messages for discrete request/response operations
  • Use ports for streaming data or stateful conversations
  • Build reconnection logic for ports that break when the service worker terminates
  • Validate sender.origin on all external messages
  • Handle orphaned content scripts after extension updates
  • Never send large payloads through messages — use storage as an intermediary

The message passing APIs are not complex individually. The complexity comes from the lifecycle interactions: service workers terminating, popups closing, tabs navigating, extensions updating. Every one of those events severs a communication channel. The extensions that survive real-world usage are the ones that treat every message channel as inherently unreliable and build recovery into the messaging layer itself.

Interactive tool

Manifest Validator

Validate your manifest.json for messaging-related permissions, externally_connectable patterns, and content script declarations.

Open tool

Interactive tool

Listing Audit

Check your Chrome Web Store listing for completeness before publishing your messaging-enabled extension.

Open tool

Continue reading

Related articles

View all posts