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.
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
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.
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.
Cross-browser variation
- Chrome / Edge:
sendMessagereturns a promise when no callback is given. Returningtruefrom 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
sendResponseplusreturn 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,
Errorobjects and class instances do not survive — which is precisely why the error has to be serialised by hand.
Verification
- 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.
- Add a handler that throws and confirm the caller receives a rejection with the original message, not
undefined. - Send to a tab with no content script and confirm you get
nullrather than an unhandled rejection. - 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.
Related
- Long-lived ports vs one-time messages — choosing the transport this wrapper sits on.
- Fixing message port closed before response errors — the bug the registry design prevents.
- Messages sent while the worker is starting — the cold-start race the retry covers.
- Message passing architecture — the parent guide to extension messaging.