Three ways to move a message
chrome.runtime.sendMessage. One shot. It reaches every extension context that has a runtime.onMessage listener, which means the service worker, the popup, the options page, and any other extension page that is open. It does not reach a content script.
chrome.tabs.sendMessage. One shot, aimed at one tab. Call it from the service worker or another extension page to reach the content script in that tab, optionally in one frame through frameId. The content script must already be running there.
chrome.runtime.connect and chrome.tabs.connect. A named port that stays open. Both sides post messages whenever they want, and both sides learn about a teardown through port.onDisconnect.
Messaging between your own contexts needs no extra permission. What it needs is a live listener on the other side.
The async response rule
Aruntime.onMessage listener answers synchronously by default. The moment it returns, the channel closes, and a sendResponse call made later is discarded. Returning true is what keeps the channel open:
async listener function returns a promise, and a promise is not true, so an async listener that calls sendResponse later still closes the channel on Chromium. Do the async work inside the listener and return true, as above. On the caller side, chrome.runtime.sendMessage returns a promise when you omit the callback, so await works there without any of this.
Give every message an explicit type, validate the payload before you act on it, and check sender before you do privileged work. A content script is your code, and the data that it forwards came from a page you do not control.
Per-browser differences
On Safari, a content script does not run until you grant website access to the extension, so
tabs.sendMessage has no receiver before that. See Safari.
If your source is written against browser.* and you also build for a Chromium target, pass --polyfill. See Cross-browser compatibility.
Console lines you will see
Copy the line that you see into search. Each one maps to one cause.Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist.
Nothing was listening. Either the target context has no runtime.onMessage listener, or, for tabs.sendMessage, no content script has been injected into that tab yet. A tab that was open before the extension loaded has no content script until it reloads, and a restricted page such as chrome:// or the extensions gallery never gets one. Inject first with chrome.scripting.executeScript, or handle the failure, as Inject scripts at runtime shows.
Unchecked runtime.lastError: The message port closed before a response was received.
A listener received the message and returned without keeping the channel open. Return true from the listener, or answer synchronously.
Uncaught Error: Extension context invalidated.
The extension reloaded while an old content script kept running in the page. That code now points at a runtime that no longer exists, and every chrome.* call from it throws. Reload the tab. During extension dev this follows a change that classifies as a full reload, which Reload and HMR describes.
Attempting to use a disconnected port object
Something posted to a port after onDisconnect fired. Clear your reference in the onDisconnect handler and open a new port when you need one.
The Extension.js way
Extension.js compiles the code that calls these APIs and does not wrap them. Your protocol is yours. What the toolchain changes is the development loop:- A change that classifies as
content-scriptsre-injects the affected entries and tears down the previous mount, so listeners are replaced instead of duplicated. A change that classifies asfullreloads the extension, and any content script still on the page from before the reload becomes theExtension context invalidatedcase above. Reload the tab. - Scripts injected from the
scripts/folder are replayed on edit underextension dev, so a port that you open from an injected script is reopened by the new copy. See Special folders. - Console output from the service worker and from content scripts is forwarded to one channel, so a message that crosses a boundary is easier to follow in a single stream.

