The Chrome Extension Messaging API: Connecting Content Scripts and Background Workers
Extension contexts cannot directly call functions in other contexts. A content script cannot reach into the background service worker and call a function; the service worker cannot directly manipulate a content script’s variables. The messaging API is the bridge. Understanding when to use sendMessage vs. long-lived connections, how to handle async responses correctly, and how to avoid silent message failures separates functional extension messaging from the brittle kind that breaks on edge cases.
Why Messaging Exists
Each extension context runs in isolation:
- Content scripts run in the page’s renderer process, sandboxed from the page’s JavaScript
- The background service worker runs in its own process with access to privileged extension APIs
- Popup and options pages run in the extension’s renderer process
Chrome’s inter-process communication (IPC) infrastructure underlies the messaging API, with the extension runtime layer abstracting it. The Chrome extension architecture documentation covers the process model.
One-Time Messages: sendMessage and sendResponse
For a single request-response exchange:
// From a content script: send message to background
chrome.runtime.sendMessage(
{ type: 'FETCH_USER_DATA', userId: 42 },
(response) => {
if (chrome.runtime.lastError) {
console.error(chrome.runtime.lastError.message);
return;
}
console.log('Got response:', response);
}
);
// Background service worker: receive and respond
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'FETCH_USER_DATA') {
fetchUserFromStorage(message.userId).then(user => {
sendResponse({ user });
});
return true; // CRITICAL: return true to indicate async response
}
});
The return true requirement is the most common source of messaging bugs. If the onMessage listener doesn’t return true, the message channel closes immediately when the listener returns. An async operation in the listener tries to call sendResponse after the channel has closed — the response is silently dropped. The sending side’s callback is never called; the promise never resolves.
Always return true when sendResponse will be called asynchronously. Return false (or nothing) for synchronous responses.
Sending Messages to Specific Tabs
From the background service worker, to send a message to a content script in a specific tab:
chrome.tabs.sendMessage(tabId, { type: 'HIGHLIGHT_ELEMENT', selector: '.result' })
.then(response => console.log(response))
.catch(err => console.error(err));
To send to the active tab:
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.tabs.sendMessage(tab.id, { type: 'SCROLL_TO_TOP' });
Note: chrome.tabs.sendMessage throws if there’s no listener in the target tab. This happens if the content script isn’t injected in the tab, or if the tab is on a page where your extension’s content scripts don’t run (like chrome:// URLs). Handle the error:
try {
await chrome.tabs.sendMessage(tab.id, message);
} catch (e) {
if (e.message.includes('Could not establish connection')) {
// Content script not present in this tab — expected in some cases
return;
}
throw e;
}
Long-Lived Connections: Port API
For ongoing communication (a streaming response, a persistent debug channel, a WebSocket-backed feature), use chrome.runtime.connect() to establish a port:
// Content script: open a port
const port = chrome.runtime.connect({ name: 'stream-channel' });
port.onMessage.addListener((message) => {
console.log('Received:', message);
});
port.onDisconnect.addListener(() => {
if (chrome.runtime.lastError) {
console.error('Port disconnected with error:', chrome.runtime.lastError.message);
}
});
port.postMessage({ type: 'SUBSCRIBE', topic: 'events' });
// Background service worker: accept connection
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'stream-channel') return;
port.onMessage.addListener((message) => {
if (message.type === 'SUBSCRIBE') {
// Send multiple messages over the connection
setInterval(() => port.postMessage({ event: 'update' }), 1000);
}
});
port.onDisconnect.addListener(() => {
// Clean up subscriptions
});
});
Ports disconnect when: the connecting context is destroyed (the tab closes, the popup closes), an error occurs, or either side calls port.disconnect(). Always clean up resources (timers, subscriptions) in the disconnect handler.
Message Schema Design
Messages are plain objects serialized via the structured clone algorithm. Functions, class instances, DOM nodes, and Promises cannot be sent — only primitives, plain objects, arrays, and typed arrays.
Define a consistent message schema:
type Message =
| { type: 'FETCH_DATA'; key: string }
| { type: 'SET_DATA'; key: string; value: unknown }
| { type: 'CLEAR_DATA' };
type Response<T> =
| { ok: true; data: T }
| { ok: false; error: string };
Typed discriminated unions make exhaustive handling easier and make it obvious when a case is missing. In JavaScript without TypeScript, using a type field on all messages and a switch statement on message.type in the listener accomplishes the same thing without types.
External Messaging
Extensions can receive messages from external web pages if the extension declares externally_connectable in the manifest. This is how web pages communicate with extensions without browser internals:
// manifest.json
{
"externally_connectable": {
"matches": ["https://yourapp.com/*"]
}
}
// From the web page at yourapp.com
chrome.runtime.sendMessage(
'YOUR_EXTENSION_ID',
{ type: 'GET_AUTH_TOKEN' },
(response) => { /* ... */ }
);
// Background service worker
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
// Verify sender.url is from your expected origin
if (!sender.url?.startsWith('https://yourapp.com')) return;
// Handle message
});
Always validate sender.url or sender.origin in onMessageExternal — any page at your declared origins can send messages.
Service Worker Activation Before Messaging
A background service worker may be inactive when a message arrives. Chrome activates it in response to messages, but there’s a startup period. If the service worker needs to restore state from storage on startup before it can handle messages, ensure state restoration happens before the onMessage listener processes requests:
// Background service worker
let state = null;
async function ensureInitialized() {
if (state) return;
const stored = await chrome.storage.local.get('appState');
state = stored.appState ?? defaultState;
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
ensureInitialized().then(() => {
// Handle message using initialized state
sendResponse(processMessage(message, state));
});
return true;
});
FAQ
Why does my sendResponse sometimes not reach the content script?
Most likely causes: (1) You’re not returning true from the onMessage listener, causing the channel to close before your async sendResponse is called. (2) The service worker stopped between when the message was received and when sendResponse was called — this can happen on slow async operations if the service worker goes idle. Keep async work brief in message handlers.
Can I use async/await in onMessage listeners?
Not directly, because an async function returns a Promise and onMessage checks if the return value is true, not if it resolves to true. Either use .then() explicitly and return true before the async work starts, or use a wrapper:
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
handleMessage(msg, sender).then(sendResponse);
return true;
});
async function handleMessage(msg, sender) { /* ... */ }
How do I broadcast a message to all tabs? Iterate over all tabs and send to each, handling the error case where a tab has no content script listener:
const tabs = await chrome.tabs.query({});
await Promise.allSettled(tabs.map(tab =>
chrome.tabs.sendMessage(tab.id, { type: 'BROADCAST_EVENT' })
));
What’s the message size limit? Chrome’s structured clone algorithm limits message size to 64MB. Practical limit is lower for performance — anything over a few hundred KB should probably be stored in extension storage and referenced by key in the message, not sent directly.