Wrapping Message Passing in Promises

Build a typed request/response layer over chrome.runtime.sendMessage — timeouts, error propagation across contexts, and a handler registry that never leaves a channel open.

Published September 18, 2026 Updated September 18, 2026 9 min read
Table of Contents

Raw chrome.runtime.sendMessage gives you an untyped payload, an error convention that differs per engine, a response channel you must remember to hold open, and no timeout at all. Every non-trivial extension ends up writing the same thin layer over it; writing it deliberately, once, removes most of the message-passing bugs you would otherwise meet one at a time. This guide is part of message passing architecture.

What the wrapper has to solve

What each layer of the wrapper is responsible forFour layers: application code calling request(), the client wrapper adding timeouts and error decoding, the handler registry dispatching by type, and the raw runtime messaging API beneath.Feature codeawait request({type:'articles:list'})looks like a normal async callClient wrappertimeout, retry, error decodeturns silence into a rejectionHandler registrydispatch by type, always respondthe channel is never left openruntime.sendMessageone-shot, untyped, engine-specific errorsthe surface being tamed
Every concern here is one the raw API leaves to the caller — which is why every codebase grows this layer eventually.

Step-by-step

1. Write the client side with a timeout

The default failure mode of sendMessage is a promise that never settles — the receiver went away mid-handler and no rejection arrives. A timeout converts that into something you can act on.

 1// rpc-client.js
 2const DEFAULT_TIMEOUT = 10_000;
 3
 4export async function request(payload, { timeout = DEFAULT_TIMEOUT } = {}) {
 5  const reply = await Promise.race([
 6    chrome.runtime.sendMessage(payload),
 7    new Promise((_, reject) =>
 8      setTimeout(() => reject(new Error(`rpc timeout: ${payload.type}`)), timeout)),
 9  ]);
10
11  if (reply && reply.__error) {
12    const err = new Error(reply.__error.message);
13    err.name = reply.__error.name;
14    throw err;
15  }
16  return reply;
17}

Execution context: any context that sends messages — popup, options page, content script or the worker itself. The setTimeout is safe here because the surrounding await keeps the calling context busy; inside a service worker, keep the timeout well below the five-minute ceiling.

2. Make errors cross the boundary

An exception thrown in a handler does not travel: the sender sees undefined. Serialising the error into the reply is what makes a remote failure debuggable.

 1// rpc-server.js
 2const handlers = new Map();
 3
 4export function handle(type, fn) {
 5  handlers.set(type, fn);
 6}
 7
 8chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
 9  const fn = msg && handlers.get(msg.type);
10  if (!fn) return false;                     // not ours — let another listener answer
11
12  Promise.resolve()
13    .then(() => fn(msg, sender))
14    .then(sendResponse)
15    .catch((err) => sendResponse({ __error: { name: err.name, message: err.message } }));
16
17  return true;                                // keep the channel open for the async reply
18});

Execution context: the service worker, registered at the top level so a cold start does not miss the first message. Returning false for unknown types is deliberate — returning true would claim the message and starve any other listener, the failure described in fixing “message port closed before response” errors.

3. Register handlers as data

1// service-worker.js
2import { handle } from "./rpc-server.js";
3import { listArticles, markRead } from "./articles.js";
4
5handle("articles:list", ({ site, limit }) => listArticles(site, limit));
6handle("articles:read", ({ id }) => markRead(id));
7handle("settings:read", () => chrome.storage.local.get("settings"));

Execution context: the top level of the service worker. Because registration is synchronous and the listener was added once, every handler is in place before the first event is dispatched — the rule set out in registering listeners at the top level.

4. Send to a tab with the same shape

Content scripts need the mirror image: the worker is the sender, the tab is the receiver, and “no receiving end” is a normal condition rather than an error.

 1export async function requestTab(tabId, payload, opts) {
 2  try {
 3    return await Promise.race([
 4      chrome.tabs.sendMessage(tabId, payload),
 5      new Promise((_, r) => setTimeout(() => r(new Error("tab rpc timeout")), opts?.timeout ?? 5_000)),
 6    ]);
 7  } catch (err) {
 8    if (/Receiving end does not exist|Could not establish connection/.test(err.message)) {
 9      return null;                            // no content script here — expected
10    }
11    throw err;
12  }
13}

Execution context: the service worker. The message text differs between Chrome and Firefox, which is why the check matches either. A null return distinguishes “nobody was listening” from “the handler failed”, and callers should branch on it.

5. Retry only what is safe to repeat

A cold worker can miss the very first message of a session. One retry covers it; more than one turns a bug into a storm.

1export async function requestWithRetry(payload) {
2  try {
3    return await request(payload, { timeout: 3_000 });
4  } catch (err) {
5    if (!/timeout|Receiving end/.test(err.message)) throw err;
6    return request(payload, { timeout: 10_000 });   // one retry, longer budget
7  }
8}

Execution context: an extension page. Only use this for reads. A retried write must be idempotent, or the second attempt duplicates the effect — the concern covered in messages sent while the worker is starting.

A request that fails inside the handlerThe popup issues a request, the worker's registry dispatches it, the handler throws, and the error is serialised into the reply and rethrown on the caller's side.PopupClient wrapperHandler registryarticles.jsrequest({type:'articles:list'})sendMessage(payload)timeout armedlistArticles(site)throw QuotaErrorsendResponse({__error})throw QuotaError
Without the serialise-and-rethrow step the popup would see undefined and report success.

Keeping the protocol small

A message layer this comfortable to use tends to grow, and the growth is where the trouble starts. Two habits keep it sustainable.

One handler per question, not per screen. A handler named popup:load that returns settings, article counts and sync status is three questions in a trench coat: it cannot be cached, it changes whenever any screen changes, and a failure in one part fails all three. Three narrow handlers compose better and let the popup render progressively.

Handlers do not reach into the DOM or into UI concerns. They read, write and compute. The temptation to have the worker “refresh the popup” inverts the dependency and breaks the moment the popup is closed — which, given the popup’s lifetime, is most of the time.

Versioning deserves a decision before you need it rather than after. Because a message may be sent by an old surface to a freshly updated worker during an auto-update, the protocol has to tolerate one version of skew:

 1export const PROTOCOL = 3;
 2
 3export function envelope(payload) {
 4  return { v: PROTOCOL, ...payload };
 5}
 6
 7// Server side: accept the current version and the one before it.
 8handle("articles:list", (msg) => {
 9  if (msg.v < PROTOCOL - 1) throw new Error("reload the extension page");
10  const limit = msg.v >= 3 ? msg.limit : 50;      // limit was added in v3
11  return listArticles(msg.site, limit);
12});

Execution context: the service worker for the handler, a shared module for the envelope. Throwing a message the UI can display is better than a silent mismatch — the surface that receives it can prompt for a reload, which is the only real fix for a context running last week’s code. The broader problem is covered in versioning message schemas across updates.

Finally, instrument the layer once rather than every call site. A wrapper that records durations makes a slow handler visible without any per-feature work:

1const t0 = performance.now();
2try { return await send(payload); }
3finally { record(payload.type, performance.now() - t0); }

Execution context: the client wrapper, in whichever context is sending. Durations over a second almost always mean a cold worker rather than slow work — a distinction worth recording separately.

Round-trip time by worker stateMeasured message round-trip durations for a warm worker, a cold start, a cold start with a large module graph, and a timed-out request.Warm worker4 mshandler cost onlyCold start, small bundle95 msCold start, large bundle380 msmodule graph evaluationTimed out3000 msno handler registered
Cold start dominates everything else — which is why the module graph, not the handler, is usually what to optimise.

Cross-browser variation

  • Chrome / Edge: sendMessage returns a promise when no callback is given. Returning true from the listener is mandatory for an async reply; returning a promise from the listener is not supported and silently closes the channel.
  • Firefox: the listener may return a promise directly, which is cleaner but not portable. The wrapper above works on both because it uses sendResponse plus return true, which Firefox also honours.
  • Safari: follows the Chrome contract. Message delivery to a just-woken background context is noticeably slower, so the retry in step 5 earns its place there more often than elsewhere.
  • All three: the payload is structured-cloned. Functions, DOM nodes, Error objects and class instances do not survive — which is precisely why the error has to be serialised by hand.

Verification

  1. From the popup console, confirm a round trip and its shape:
1await request({ type: "settings:read" });
2// { settings: { theme: "auto", syncHour: 7 } }

Execution context: the popup’s DevTools console, which shares the popup document’s scope. If this hangs for the full timeout, the worker has no handler registered for that type.

  1. Add a handler that throws and confirm the caller receives a rejection with the original message, not undefined.
  2. Send to a tab with no content script and confirm you get null rather than an unhandled rejection.
  3. Evict the worker from chrome://extensions, then send immediately and confirm the retry path succeeds.

FAQ

Should I use a long-lived port instead?

For a stream of messages or a subscription, yes — the trade-off is laid out in long-lived ports vs one-time messages. For request/response, one-shot messages with this wrapper are simpler and survive worker eviction without reconnect logic.

Why not return the promise from the listener?

Because Chrome does not support it. The listener’s return value is interpreted as “keep the channel open” only when it is exactly true; a promise is truthy but not true, and Chrome closes the channel immediately.

Is a ten-second timeout too long?

For a user-facing read, yes — three seconds with one retry gives better feedback. Reserve the longer budget for operations that genuinely take time, and show progress rather than extending the wait silently.

Other Core APIs & Cross-Browser Data Management Resources