Skip to main content
An extension runs in several isolated contexts. The service worker holds the privileged code, content scripts see the page, and the popup or options page holds the interface. None of them share memory, so every value that crosses a boundary crosses as a message. This page explains which call to use for each direction, the rule that decides whether an async answer arrives, and the console lines that a broken exchange prints.

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

A runtime.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:
Two details follow from that rule. An 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.
A long-lived exchange uses a port instead, and the port carries a name so one listener can tell its callers apart:

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-scripts re-injects the affected entries and tears down the previous mount, so listeners are replaced instead of duplicated. A change that classifies as full reloads the extension, and any content script still on the page from before the reload becomes the Extension context invalidated case above. Reload the tab.
  • Scripts injected from the scripts/ folder are replayed on edit under extension 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.
Keep the privileged work in the service worker, keep the content script narrow, and keep the payload small. A content script that forwards a whole page snapshot is a slower and larger attack surface than one that forwards the three fields the feature needs.

See also