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.