> ## Documentation Index
> Fetch the complete documentation index at: https://extension.js.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Message passing between extension contexts

> Send messages between the service worker, content scripts, and extension pages with runtime.sendMessage, tabs.sendMessage, and long-lived ports, including the async response rule and the connection errors it causes.

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.

This page is part of the Extension.js documentation. Extension.js builds and runs browser extensions from one `manifest.json` for Chrome, Edge, Firefox, and Safari, and the messaging APIs below are the browser's own. To try the examples, scaffold a project with `npx extension@latest create my-extension`.

## 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`.

| Property | `runtime.sendMessage` | `tabs.sendMessage` | `connect` port |
| - | - | - | - |
| Direction | Any extension context to the others | Extension context to one tab | Two-way, for as long as the port is open |
| Reaches a content script | No | Yes, in that tab | Yes, through `tabs.connect` |
| Lifetime | One request and one response | One request and one response | Until either side disconnects |
| Answer | `sendResponse`, or the returned promise | `sendResponse` | Another `postMessage` |
| Use it for | A single question | Reaching page code you injected | A stream of updates or a subscription |

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:

```ts theme={null}
// Wrong: the listener returns before the await settles, so the channel closes.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  fetchSettings().then((settings) => sendResponse({ ok: true, settings }));
});

// Right: returning true keeps the channel open until sendResponse runs.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  fetchSettings().then((settings) => sendResponse({ ok: true, settings }));
  return true;
});
```

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.

```ts theme={null}
type Message =
  | { type: "settings:get" }
  | { type: "settings:set"; payload: { theme: "light" | "dark" } };

chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
  if (!message || typeof message.type !== "string") {
    sendResponse({ ok: false, error: "invalid_message" });
    return;
  }

  if (message.type === "settings:get") {
    getSettings().then((settings) => sendResponse({ ok: true, settings }));
    return true;
  }

  sendResponse({ ok: false, error: "unknown_message" });
});
```

A long-lived exchange uses a port instead, and the port carries a name so one listener can tell its callers apart:

```ts theme={null}
// Content script.
const port = chrome.runtime.connect({ name: "highlights" });
port.postMessage({ type: "subscribe" });
port.onMessage.addListener((update) => render(update));

// Service worker.
chrome.runtime.onConnect.addListener((port) => {
  if (port.name !== "highlights") return;
  port.onMessage.addListener((message) => port.postMessage({ ok: true }));
  port.onDisconnect.addListener(() => stopWatching(port));
});
```

## Validate messages between a content script and the background

A content script runs inside a page that you do not control. The page can hand it anything through the DOM or `window.postMessage`, and the content script forwards that data to the background. Chrome documents content scripts as less trustworthy than the service worker for this reason. Treat every message that reaches the background as untrusted input, the way a server treats a request body.

**Check who is calling.** Every listener receives a `sender` object, the `runtime.MessageSender` type. These are the fields that matter:

| Field | What it holds | Use it to |
| - | - | - |
| `sender.id` | The id of the extension that sent the message | Reject other extensions on `runtime.onMessageExternal` |
| `sender.url` | The URL of the page or frame that sent it, the iframe URL when the sender is in a frame | Compare `new URL(sender.url).origin` against the sites that you expect |
| `sender.origin` | The origin of the sender, which can differ from `url` (`about:blank`) or be opaque (a sandboxed iframe) | The same check, on browsers that set it |
| `sender.tab` | The `tabs.Tab` that sent it, present only when the sender is a tab | Require it for a message type that only a content script may send |
| `sender.frameId` | `0` for the top frame, positive for a child frame, present only with `sender.tab` | Refuse a privileged request from an iframe |

`runtime.onMessage` fires only for your own extension's contexts, so `sender.id` is always your own id there. A message from another extension, or from a web page that `externally_connectable` admits, arrives at `runtime.onMessageExternal` instead, and that is where `sender.id` and `sender.url` decide everything.

**Validate the shape before you act.** Keep an allowlist of message types, check every field's type and size, and refuse anything else. Never turn a string from a message into code: no `eval`, no `new Function`, no `innerHTML`, and no `chrome.scripting.executeScript` call whose code comes from the message. Write text with `textContent`.

```ts theme={null}
const ALLOWED_ORIGINS = new Set(["https://example.com"]);

function isSaveNote(message: unknown): message is { type: "note:save"; text: string } {
  if (typeof message !== "object" || message === null) return false;
  const candidate = message as { type?: unknown; text?: unknown };
  return (
    candidate.type === "note:save" &&
    typeof candidate.text === "string" &&
    candidate.text.length <= 2000
  );
}

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  // Only a content script sends this type, so a sender without a tab is wrong.
  if (!sender.tab || !sender.url) {
    sendResponse({ ok: false, error: "forbidden" });
    return;
  }

  if (!ALLOWED_ORIGINS.has(new URL(sender.url).origin)) {
    sendResponse({ ok: false, error: "forbidden" });
    return;
  }

  if (!isSaveNote(message)) {
    sendResponse({ ok: false, error: "invalid_message" });
    return;
  }

  saveNote(sender.tab.id, message.text).then(() => sendResponse({ ok: true }));
  return true;
});
```

**Decide who may connect from outside.** Without an `externally_connectable` key, every other extension can message yours and no web page can. Declare the key to narrow or widen that. `ids` lists the extensions that may connect, and `"*"` admits all of them. `matches` lists the web pages that may call `runtime.sendMessage` with your extension id. Those messages land on `runtime.onMessageExternal` and `runtime.onConnectExternal`, never on `onMessage`, so give them their own listener and check `sender.url` against the pattern that you declared.

```json theme={null}
{
  "externally_connectable": {
    "ids": ["abcdefghijklmnopabcdefghijklmnop"],
    "matches": ["https://app.example.com/*"]
  }
}
```

**A port changes when you check, not what.** `port.sender` is set on the port that `runtime.onConnect` and `runtime.onConnectExternal` deliver, so check `port.name` and the sender once, when the port opens. The identity of the port is then fixed, but every payload that arrives on `port.onMessage` is still page data, so run it through the same type guard. Call `port.disconnect()` on the first bad message.

Where Firefox differs:

* `sender.origin` exists from Firefox 126. Derive the origin from `sender.url` to cover older versions and Chrome in one line.
* Firefox does not support `externally_connectable`, so a web page can never message a Firefox extension, and `runtime.onMessageExternal` fires there only for another extension. Safari 15.4 supports the key with `matches` only.
* `browser.runtime.onMessage` honors a returned promise, which the table below covers.

Extension.js compiles these listeners as written and adds no check of its own, so this part is plain browser API. One dev-time detail. Under `extension dev`, the reload bridge relays console output to the service worker over a port named `__extjs-bridge-log__`. A devtools page relays it with `runtime.sendMessage` instead, as an object that carries an `__extjsBridgeLog` key. A `runtime.onConnect` listener that checks `port.name`, and an `onMessage` listener that rejects a message without a known `type`, ignore both. `extension build` output carries none of it.

## Per-browser differences

| Capability | Chromium | Firefox | Safari |
| - | - | - | - |
| Namespace | `chrome.*` | `browser.*` with promises, and `chrome.*` is also available | Not covered by these docs |
| Promise returned from an `onMessage` listener | Not honored, return `true` and call `sendResponse` | Honored on `browser.runtime.onMessage` | Not covered by these docs |
| Background context that receives | Service worker, restarted on demand | Event page declared through `background.scripts` | Not covered by these docs |
| Effect on background lifetime | A received message and an open port keep the worker awake, so do not use a port as a timer | Event page, same reasoning applies | Not covered by these docs |

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](/docs/browsers/safari).

If your source is written against `browser.*` and you also build for a Chromium target, pass `--polyfill`. See [Cross-browser compatibility](/docs/features/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](/docs/implementation-guide/inject-scripts) 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](/docs/features/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](/docs/features/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

* [Storage](/docs/implementation-guide/storage)
* [Background scripts / service worker](/docs/implementation-guide/background)
* [Content scripts](/docs/implementation-guide/content-scripts)
* [Inject scripts at runtime](/docs/implementation-guide/inject-scripts)
* [Native messaging](/docs/implementation-guide/native-messaging)
* [Reload and HMR](/docs/features/reload-and-hmr)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.