chrome.tabs.sendMessage vs chrome.runtime.sendMessage: which one, and why messages vanish
6 min readModerok team
When to use chrome.tabs.sendMessage vs chrome.runtime.sendMessage in a Manifest V3 extension, and the routing rules that make messages silently disappear.
The choice between chrome.tabs.sendMessage and chrome.runtime.sendMessage is decided by direction, not preference. Sending to a content script requires chrome.tabs.sendMessage(tabId, message). Sending from a content script, or between extension pages and the service worker, requires chrome.runtime.sendMessage(message). Picking the wrong one is the most common reason a message vanishes with no error you would notice.
// service-worker.js or popup.js: extension -> content script in a specific tab
async function askContentScript() {
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
return chrome.tabs.sendMessage(tab.id, { type: "get-selection" });
}
// content-script.js: content script -> service worker
// (content scripts are classic scripts, so no top-level await)
async function notifyWorker() {
return chrome.runtime.sendMessage({ type: "selection-copied" });
}
Both are received by the same event, chrome.runtime.onMessage. Only the routing differs.
The documented rule for chrome.runtime.sendMessage vs chrome.tabs.sendMessage
The runtime.sendMessage reference is blunt. It says that "If sending to your extension, the runtime.onMessage event will be fired in every frame of your extension (except for the sender's frame), or runtime.onMessageExternal, if a different extension." Then, explicitly: "Note that extensions cannot send messages to content scripts using this method. To send messages to content scripts, use tabs.sendMessage." (chrome.runtime reference)
The phrase "in every frame of your extension" (chrome.runtime reference) means extension pages: the popup, the options page, a side panel, the background service worker. A content script is none of those. It runs in the web page's frame, and the only handle Chrome exposes for reaching it is the tab it lives in.
tabs.sendMessage is the mirror image: "Sends a single message to the content script(s) in the specified tab. The runtime.onMessage event is fired in each content script running in the specified tab for the current extension." (chrome.tabs reference)
Why the wrong one fails so quietly
Two failure shapes account for most of the confusion.
A service worker calls runtime.sendMessage and expects a content script to answer. The message is delivered, just not where you think: it goes to every other extension frame. If the popup is closed and nothing else listens, the promise rejects. If the popup is open with an onMessage listener that ignores unknown types, the call resolves with whatever that listener happens to answer, and nothing in the console hints that the content script was never in the conversation.
A content script calls chrome.tabs.sendMessage. This throws a TypeError on chrome.tabs, because content scripts do not have the Tabs API. Chrome's content scripts guide lists exactly what they can reach: "Content scripts can access the following extension APIs directly:" and then names dom, i18n, storage, runtime.connect(), runtime.getManifest(), runtime.getURL(), runtime.id, runtime.onConnect, runtime.onMessage and runtime.sendMessage(). Nothing else. A content script needing a tab-scoped action messages the service worker instead.
Note that chrome.tabs.sendMessage does not require the tabs permission, which "does not give access to the browser.tabs namespace" (chrome.tabs reference). Declaring "tabs" just so sendMessage works buys an install-time warning for nothing.
The sender's own frame never hears itself
The parenthetical "(except for the sender's frame)" in that same runtime.sendMessage description catches popups that both send and listen. A popup calling chrome.runtime.sendMessage will not fire its own onMessage listener, and neither will the service worker when it broadcasts. To make a context react to something it originated, call the function directly, or write to chrome.storage and listen on chrome.storage.onChanged.
One tab, many frames
chrome.tabs.sendMessage with no options targets every frame in the tab. Both option properties narrow that. Of frameId the reference says: "Send a message to a specific frame identified by frameId instead of all frames in the tab." Of documentId (Chrome 106+): "Send a message to a specific document identified by documentId instead of all frames in the tab." (chrome.tabs reference)
That default matters when your manifest sets "all_frames": true. Every injected frame gets the message and may call sendResponse. MDN tabs.sendMessage states the outcome plainly: "If several frames respond to the message, the promise is resolved to one of answers." You get one answer, and the rest are discarded. If the reply is a page-level fact such as the document title, answer only from the top frame:
// content-script.js, injected with "all_frames": true
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type !== "get-title") return;
if (window.top !== window) return; // let the top frame answer
sendResponse({ title: document.title });
});
The sender argument is also how a single listener tells the two directions apart. sender.tab is "The tabs.Tab which opened the connection, if any. This property will only be present when the connection was opened from a tab (including content scripts), and only if the receiver is an extension, not an app." (chrome.runtime reference) Note the "including content scripts": the property marks a tab as the origin, and a content script is one case of that, not the only one. An extension page opened in its own tab has sender.tab set too. What you can rely on is the negative: no sender.tab means the message came from the popup, the service worker, or another extension frame that is not hosted in a tab.
What still breaks after you pick the right API
Correct routing is necessary, not sufficient.
No listener on the other end. tabs.sendMessage to a tab with no injected content script rejects with the runtime error every extension developer recognises, Could not establish connection. Receiving end does not exist. Freshly installed and reloaded extensions have no content scripts in already-open tabs, the usual trigger. The fix, and the four other causes, are in Fix "Could not establish connection. Receiving end does not exist.".
Several listeners, one response. Chrome's message passing guide: "If multiple listeners are registered for onMessage, only the first listener to respond, reject, or throw an error will affect the sender; all other listeners will run, but their results will be ignored." Adding a second feature's listener can therefore break the first feature's replies.
An async listener. Chrome's message passing guide sets the default: "By default, the sendResponse callback must be called synchronously." Deferring it takes an explicit signal: "To respond asynchronously using sendResponse(), return a literal true (not just a truthy value) from the event listener." Chrome now accepts a returned promise too, with a caveat: "From Chrome 148, you can return a promise from a message listener to respond asynchronously. This update is rolling out gradually, so you may find that it's not yet available in all users' browsers." An async function returns a promise, never the literal true, so return true stays the portable signal. That failure, and worker termination mid-reply, are in Fix "The message port closed before a response was received.".
Firefox differences
Firefox routes messages the same way: "Extensions cannot send messages to content scripts using this method. To send messages to content scripts, use tabs.sendMessage." (MDN runtime.sendMessage) The documented difference is reach.
Reach. MDN describes tabs.sendMessage as sending "from the extension's background scripts (or other privileged scripts, such as popup scripts or options page scripts) to any content scripts or extension pages/iframes that belong to the extension and are running in the specified tab." Chrome's own wording stops at "the content script(s) in the specified tab." If you embed an extension page in a tab, do not assume both browsers deliver to it.
Where the relay pattern earns its place
Once the routing is clear, much of an extension's structure follows from one rule: anything needing privileged APIs lives in the service worker, and content scripts reach it with chrome.runtime.sendMessage. The same relay is useful wherever you want a single place that owns a job, rather than the same code running in every injected frame.
The Moderok SDK can be initialised in a popup, options page or content script as well as the service worker: "Each context (background, popup, options, content script) has its own SDK instance" (Moderok docs). It ships no message bus of its own, so if you would rather keep one place that calls Moderok.track(), the relay is the way to do it: chrome.runtime.sendMessage from the UI script, Moderok.track() in the service worker's onMessage handler. Either way Moderok.init() belongs at top level in the service worker so install and update events still fire, and the relay is written up in the Moderok docs.
Get the direction right and most of the work is done. The failures that remain are about listeners, not APIs.