Handling Errors Across Message Boundaries

Propagate errors through chrome.runtime messaging in MV3: why thrown errors vanish, an ok/error envelope, stable error codes, distinguishing no receiver and invalidated context, timeouts, and logging with context.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

The popup asks the worker to save an item; the worker’s handler throws because the API returned 403; the popup’s await sendMessage(...) resolves to undefined and the UI says “Saved”. Exceptions do not cross message boundaries. Neither does an Error’s class, stack or custom fields — the structured clone algorithm turns an Error into a plain object in some engines and drops it in others. Meanwhile the sender can fail in ways unrelated to the handler: no listener exists, the extension was updated and the context invalidated, or the worker took too long. Robust messaging needs an explicit error protocol. This guide defines one. It belongs to message passing architecture.

Where errors get lost

There are two places. On the receiving side, a handler that throws or returns a rejected promise does not send anything back: in Chrome the listener returned true, so the channel stays open until it is garbage-collected, and the sender eventually receives undefined — or an error saying the message port closed before a response was received. On the sending side, sendMessage itself rejects for transport problems: “Could not establish connection. Receiving end does not exist.” when no listener is registered (for example, a content script that was never injected), and “Extension context invalidated.” when the sending content script outlived an extension update. Each of these deserves a different reaction — retry, inject, reload the page, or show the user an error — and none of them can be distinguished unless the protocol makes them distinguishable.

Classifying a failed messageDecision tree separating transport failures — no receiver, context invalidated, timeout — from application failures reported in the reply envelope.How did it fail?send rejectedTransportno receiver / invalidatedInject or reloadnot the handler's faultno reply in timeTimeouthandler hungRetry oncethen reportreply ok:falseApplicationerror.codeHandle by codeshow or recover
Transport errors reject the send; application errors arrive inside a reply.

Step-by-step: an explicit error protocol

1. Always reply with an envelope

 1// sw.js
 2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
 3  const handler = HANDLERS[msg?.type];
 4  if (!handler) return;
 5  Promise.resolve()
 6    .then(() => handler(msg, sender))
 7    .then(
 8      (data) => sendResponse({ ok: true, data }),
 9      (err) => sendResponse({ ok: false, error: toWire(err) }),
10    );
11  return true;
12});
13
14function toWire(err) {
15  return {
16    code: err?.code ?? (err?.name === "AbortError" ? "aborted" : "internal"),
17    message: String(err?.message ?? err),
18    retryable: Boolean(err?.retryable),
19  };
20}

Execution context: the service worker. Every outcome produces exactly one reply, success or failure, so the sender never waits on a closed channel. The error is converted to plain, cloneable data — a stable code for programs, a message for humans, a retryable hint — because an Error object does not survive the boundary intact. Promise.resolve().then(...) also catches handlers that throw synchronously.

2. Define stable error codes

 1// shared/errors.js
 2export const E = Object.freeze({
 3  UNAUTHENTICATED: "unauthenticated",     // sign in again
 4  FORBIDDEN:       "forbidden",           // account lacks access
 5  NOT_FOUND:       "not-found",
 6  INVALID:         "invalid-request",     // sender bug or tampered message
 7  RATE_LIMITED:    "rate-limited",
 8  OFFLINE:         "offline",
 9  INTERNAL:        "internal",
10});
11
12export class AppError extends Error {
13  constructor(code, message, { retryable = false } = {}) { super(message); this.code = code; this.retryable = retryable; }
14}

Execution context: a shared module. Senders branch on codes, never on message text, which changes with wording and language. A short list covers most extensions; resist one code per situation. Handlers throw AppError with the right code; anything else becomes internal.

Error codes and how the UI respondsCommon application error codes in the reply envelope, whether they are retryable, and the response the popup or side panel should show.CodeRetryableUI responseunauthenticatedAfter sign-inShow sign-inforbiddenNoExplain accessofflineYesQueued — will retryrate-limitedLaterTry again shortlyinvalid-requestNoReport buginternalMaybeGeneric error + report
Codes let every UI surface react the same way to the same failure.

3. Unwrap on the sending side, with transport errors classified

 1// shared/send.js
 2export async function send(msg, { timeoutMs = 15_000 } = {}) {
 3  let reply;
 4  try {
 5    reply = await Promise.race([
 6      chrome.runtime.sendMessage(msg),
 7      new Promise((_, reject) => setTimeout(() => reject(new AppError("timeout", "no reply", { retryable: true })), timeoutMs)),
 8    ]);
 9  } catch (err) {
10    throw classifyTransport(err);
11  }
12  if (reply === undefined) throw new AppError("no-reply", `nothing handled ${msg.type}`);
13  if (!reply.ok) throw new AppError(reply.error.code, reply.error.message, { retryable: reply.error.retryable });
14  return reply.data;
15}
16
17function classifyTransport(err) {
18  const m = String(err?.message ?? err);
19  if (/context invalidated/i.test(m)) return new AppError("context-invalidated", m);
20  if (/receiving end does not exist|could not establish connection/i.test(m)) return new AppError("no-receiver", m, { retryable: true });
21  return err instanceof AppError ? err : new AppError("transport", m);
22}

Execution context: any sender. Callers now get one kind of exception with a code, whether the failure happened in transport or in the handler. A timeout protects UI from a handler that never replies. context-invalidated in a content script means the extension updated underneath it; the right reaction is to stop and, if appropriate, ask the user to reload the page.

A handler failure reaching the UI intactThe popup sends save; the handler's API call returns 403 and it throws AppError forbidden; the listener wraps it in an ok:false envelope; the popup's send helper throws AppError with code forbidden; the popup shows an access message.PopupListenerHandlersend({type:'items:save'})dispatchthrow AppError('forbidden'){ok:false, error:{code:'forbidden'}}show 'No access to this folder'
The code survives the trip; the stack trace stays in the worker's logs.

4. Handle “no receiver” for content scripts

 1// sw.js — ask a tab, injecting the content script if it is missing
 2export async function askTab(tabId, msg) {
 3  try {
 4    return await chrome.tabs.sendMessage(tabId, msg);
 5  } catch (err) {
 6    if (!/receiving end does not exist/i.test(err.message)) throw err;
 7    await chrome.scripting.executeScript({ target: { tabId }, files: ["content.js"] });
 8    return chrome.tabs.sendMessage(tabId, msg);
 9  }
10}

Execution context: the service worker. “Receiving end does not exist” from tabs.sendMessage usually means the tab was open before the extension was installed or updated, so the content script was never injected. Injecting and retrying once fixes it; if injection fails too, the page is one the extension cannot touch. See injecting into already open tabs after install.

5. Log with context on the side that knows it

1// in the listener's error branch, before replying
2console.error("[msg]", msg?.type, "from", sender.tab ? `tab ${sender.tab.id} frame ${sender.frameId}` : sender.url, err);
3reportError(err, { messageType: msg?.type, senderKind: sender.tab ? "content" : "page" });

Execution context: the service worker. The handler side has the stack trace and the context; the sender has only a code. Log there, and send the full error to your error reporter with the message type and sender kind — never the payload, which may contain user data. See capturing uncaught errors in every context.

6. Make retries explicit and bounded

1export async function sendWithRetry(msg, attempts = 2) {
2  for (let i = 0; ; i++) {
3    try { return await send(msg); }
4    catch (err) {
5      if (!err.retryable || i + 1 >= attempts) throw err;
6      await new Promise((r) => setTimeout(r, 250 * 2 ** i));
7    }
8  }
9}

Execution context: any sender. Only errors marked retryable are retried, at most a couple of times with backoff. no-receiver while the worker is still starting, timeout from a cold start and offline are typical candidates. Never retry invalid-request or forbidden.

7. Show errors consistently

1// shared/ui-errors.js
2export function messageFor(err) {
3  switch (err.code) {
4    case "unauthenticated": return "Please sign in again.";
5    case "offline": return "You're offline — we'll save this when you reconnect.";
6    case "context-invalidated": return "Readable was updated. Reload this page to continue.";
7    default: return "Something went wrong. Please try again.";
8  }
9}

Execution context: a shared module used by the popup, side panel and content scripts. One mapping from codes to messages keeps every surface consistent and makes localisation a single file. Pair generic messages with a “Copy details” control that includes the code for support.

Common mistakes

  • Throwing in handlers and expecting the sender to catch. It receives undefined.
  • Sending Error objects in replies. Fields and class are lost; send plain data.
  • Branching on message text. Use codes.
  • No timeout. A hung handler freezes the UI forever.
  • Retrying everything. Retries amplify bugs and load; retry only what is marked retryable.

Cross-browser variation

  • Chrome / Edge: transport errors as quoted; thrown errors in listeners do not propagate; return true required.
  • Firefox: if a listener returns a rejected promise, Firefox rejects the sender’s promise with the error message — but not its custom fields; the envelope works identically and is more informative.
  • Safari: behaves like Firefox for returned promises; transport error messages differ in wording, so classify by several patterns.

Verification

  1. Make a handler throw AppError("forbidden") and confirm the sender catches an error with code === "forbidden".
  2. Send a message type with no handler and confirm no-reply rather than a silent undefined.
  3. Reload the extension and use a stale content script; confirm context-invalidated and the reload prompt.
  4. Make a handler hang and confirm the sender times out after 15 seconds.

FAQ

Can I include the stack trace in the reply?

For development builds, yes; in production, keep stacks in your error reporter rather than in replies that may be shown or logged in pages.

Should ports use the same envelope?

Yes — { ok, data | error } per message, or separate error message types, so the UI handles both channels the same way.

What about errors in content scripts the worker asks to do something?

Apply the same envelope in the content script’s onMessage listener.

Other Core APIs & Cross-Browser Data Management Resources