Message Passing in Chromium Browser Extensions

This article provides a comprehensive overview of how Chromium browser extensions facilitate communication between content scripts and background service workers using message passing. Because content scripts execute within the context of a specific web page and background service workers run in a separate, isolated extension environment, they cannot directly access each other's variables or functions. To bridge this gap, the Chromium Extension API provides two primary mechanisms for exchanging JSON-serializable data: one-time requests for simple interactions and long-lived connections for continuous data streams.

One-Time Requests

One-time requests are ideal for sending a single message and optionally receiving a single response. This works similarly to a standard HTTP request-response cycle.

Sending from Content Script to Service Worker

To send a message from a content script to the background service worker, use the chrome.runtime.sendMessage method.

// content-script.js
chrome.runtime.sendMessage({ action: "fetchUserData", userId: 123 }, (response) => {
  console.log("Received from background:", response.data);
});

Sending from Service Worker to Content Script

Sending a message from the background service worker to a content script requires specifying which tab should receive the message. You must use chrome.tabs.sendMessage and provide the target tab's ID.

// service-worker.js
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
  if (tabs.length > 0) {
    chrome.tabs.sendMessage(tabs[0].id, { action: "changeBackgroundColor", color: "blue" }, (response) => {
      console.log("Content script responded:", response.status);
    });
  }
});

Receiving One-Time Messages

Both content scripts and service workers use the exact same event listener, chrome.runtime.onMessage, to receive messages.

// Used in either content-script.js or service-worker.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === "fetchUserData") {
    // Perform synchronous logic
    sendResponse({ data: "User: John Doe" });
  }
});

Handling Asynchronous Responses

If the message receiver needs to perform an asynchronous task (like a fetch request) before replying, you must explicitly return true from the onMessage event listener. This tells the browser to keep the message channel open until sendResponse is called.

// service-worker.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === "fetchAsyncData") {
    fetch("https://api.example.com/data")
      .then(res => res.json())
      .then(data => sendResponse({ data: data }))
      .catch(err => sendResponse({ error: err.message }));
      
    return true; // Keeps the message channel open for the async response
  }
});

Long-Lived Connections

If your extension requires persistent communication or needs to send multiple messages back and forth over time, you should establish a long-lived connection. This opens a persistent channel (a runtime.Port) between the content script and the service worker.

Establishing a Connection

You can initiate a connection from either end using chrome.runtime.connect (from the content script) or chrome.tabs.connect (from the service worker). You can also name the channel to distinguish between different types of connections.

// content-script.js
const port = chrome.runtime.connect({ name: "data-stream" });

// Send a message through the port
port.postMessage({ status: "connected" });

// Listen for messages from the service worker on this port
port.onMessage.addListener((msg) => {
  if (msg.command === "start") {
    port.postMessage({ data: "Streaming data..." });
  }
});

Listening for Connections

The receiving end must listen for the connection attempt using the chrome.runtime.onConnect event listener. Once connected, it uses the provided port object to send and receive messages.

// service-worker.js
chrome.runtime.onConnect.addListener((port) => {
  if (port.name === "data-stream") {
    port.onMessage.addListener((msg) => {
      if (msg.status === "connected") {
        // Reply through the same port
        port.postMessage({ command: "start" });
      } else if (msg.data) {
        console.log("Received stream:", msg.data);
      }
    });

    // Optional: Handle port disconnection
    port.onDisconnect.addListener(() => {
      console.log("Port disconnected");
    });
  }
});

Closing Connections

A port remains open until the tab containing the content script is closed, the extension is unloaded, or either side explicitly calls port.disconnect(). Keeping a port open will also prevent the Manifest V3 background service worker from going idle while the connection is active.