Quick answer
Use one-time messages for a request and response, and ports for a stream of related updates. Send plain JSON-compatible records with a specific type, validate them in the receiver, and return a stable success/error shape. A TypeScript interface alone does not validate data arriving from another context.
runtime.sendMessage reaches extension contexts; tabs.sendMessage reaches content scripts in a tab. Neither guarantees that the receiver still exists after navigation or that a job completed merely because a message was sent. Separate transport errors from application errors.
Practical implementation steps
- Name requests by allowed behavior, such as
settings/readorreport/save. Avoid a dispatcher that accepts arbitrary method names, storage keys, or URLs from content scripts. - Validate field types, sizes, enum values, and the sender’s authority. Use
sender.tab,frameId, origin, and document identity where relevant; a payload’s claimed tab identifier is not trustworthy authorization. - Register the listener at worker module evaluation. For broad compatibility, use a non-async listener that returns literal
truewhen it will callsendResponselater. Current documentation describes Promise-returning listener support from Chrome 148 with gradual rollout; check your browser floor before adopting it. - Give one listener ownership of each request type. Several listeners may run, but only one response wins. An unrelated async listener can inadvertently respond with an undefined value.
- Catch rejected sends when a tab navigates or no script is installed. Use a user-facing retry path rather than silently reinjecting a mutating feature. For streamed work, handle port disconnect and bind updates to a document or run identifier.
LocaleStress’s page modification and report review are useful examples of distinct responsibilities. A report request should identify a particular run and return data; it should not accidentally instruct any page to repeat a text mutation.
Implementation example
This worker returns only a harmless preference and uses storage permission. Authorization must become stricter if a future request exposes private records or performs privileged work.
chrome.runtime.onMessage.addListener((message, _sender, respond) => {
if (typeof message !== "object" || message === null
|| message.type !== "settings/read") return;
chrome.storage.local.get({ highlights: true }).then(
({ highlights }) => respond({ ok: true, highlights: highlights === true }),
() => respond({ ok: false, error: "storage-unavailable" }),
);
return true;
});
async function readHighlights() {
const result = await chrome.runtime.sendMessage({ type: "settings/read" });
if (!result || result.ok !== true || typeof result.highlights !== "boolean") {
throw new Error("Settings unavailable");
}
return result.highlights;
}
The caller must catch readHighlights() rejection. Its validation covers a malformed response as well as an explicit application error; a transport rejection follows the same recovery path. Do not return DOM nodes, functions, or class instances expecting their identity and methods to survive Chrome’s JSON messaging serialization.
Tradeoffs and alternatives
Ports reduce repeated connection setup for progress updates, but introduce disconnect and multiple-frame handling. A one-time message is simpler for a preference read. Large reports may be better stored once and retrieved by identifier instead of copied through every progress update.
Common mistakes
- Returning a truthy object instead of literal
truefor callback-based asynchronous responses. - Trusting a TypeScript cast as receiving-side validation.
- Letting page data select privileged URLs or API methods.
- Retrying a destructive operation without an operation identifier.
Production checklist
- Each request has one owner and bounded validation.
- Sender authority is checked for privileged operations.
- Responses and transport failures are both handled.
- Navigation and port disconnect are recoverable.
- Retry semantics distinguish reads from side effects.
FAQ
Does return true keep the worker permanently alive?
No. It keeps an asynchronous response channel open; it is not a general lifetime strategy.
Should I use messages for shared memory?
Messages transfer values between contexts. Use an explicit owner and source of truth rather than pretending every context holds the same mutable object.
What is the next step?
Test one request with a valid response, malformed input, unavailable storage, and a receiver that disappears. That establishes a usable contract before adding more request types.
Primary sources
Related guides and examples
Need this implemented in a real product?
Explore relevant product case studies or share the context for a web application, Chrome extension, API, or SaaS build.