Definition
In a browser extension, Content Scripts, Background Service Workers, and Side Panels cannot directly call one another's functions or share in-memory objects. Message-based RPC (Remote Procedure Call, a pattern for sending a structured request to another execution context and receiving the result) is an application layer built on top of Chrome's message APIs with request schemas, operation routers, handlers, and consistent success and failure response shapes so cross-context work feels like a function call.
Chrome does not provide a complete RPC framework. The platform provides transport primitives such as runtime.sendMessage, runtime.onMessage, and tabs.sendMessage; the extension decides which messages are allowed and how errors are represented.
Why it matters
Imagine saving text selected on a web page and showing the saved list in a Side Panel. The Content Script can read the page's DOM Range, but if it also owns central storage, data rules become scattered across tabs. The Background Service Worker is a better place to handle IndexedDB and permissions in one place, but it cannot directly read a specific tab's DOM. The Side Panel is another separate execution context, so importing a save function does not mean it calls the same in-memory instance.
If contexts exchange only a string type and arbitrary objects, invalid payloads fail deep inside handlers, and transport errors become hard to distinguish from business errors. Validating unknown values against schemas at the request boundary, letting a router execute only supported operations, and returning results in a stable response envelope make the contract between execution contexts visible in one place.
How it works
A typical flow looks like this.
UI or Content Script
→ sendRequest({ type, payload, requestId })
→ Chrome message transport
→ Background onMessage listener
→ runtime schema validation
→ operation router
→ handler / transaction
→ { ok: true, data } | { ok: false, error }
The receiver does not trust the message and accepts it as unknown. Only requests that pass schema validation reach the handler registered for their type. Handler exceptions are not serialized directly; they are mapped to error codes and safe messages that callers can handle. For operations where a duplicate request could create data twice, add a requestId with idempotency tracking or enforce uniqueness in storage.
A command response and state updates in other screens are separate concerns. The response returns only to the screen that sent the request, so other screens may need a broadcast saying, "data changed." Receiving screens invalidate the relevant queries and read the single source of truth again, such as IndexedDB. If the event carries only a small signal instead of a full data copy, a screen opened later can still read the latest value from the source of truth.
Practical application
You can keep transport and contract thinly separated like this.
type Response<T> =
| { ok: true; data: T }
| { ok: false; error: { code: string; message: string } };
browser.runtime.onMessage.addListener(
async (raw): Promise<Response<unknown>> => {
const parsed = requestSchema.safeParse(raw);
if (!parsed.success) {
return {
ok: false,
error: { code: "INVALID_REQUEST", message: "Invalid request" },
};
}
return routeRequest(parsed.data);
},
);
Tab-dependent work, such as DOM selection and highlighting, can be sent to the Content Script with tabs.sendMessage(tabId, request). Work that needs central coordination, such as storage, permission checks, or multi-record transactions, belongs in Background handlers. The UI handles only the typed result from sendRequest() instead of transport details.
After a successful write, broadcast a small signal that includes the changed scope. For example, a screen that receives { type: "items/changed", sourceId } invalidates only query keys that include sourceId. Do not clear every cache or store the event payload as if it were the new source of truth.
Trade-offs
- Explicit contracts clarify execution boundaries and make testing easier, but each operation needs a maintained schema and error mapping.
- Re-fetching after a change signal preserves a single source of truth and works for screens opened late, but it adds another read after a write. Only consider limited optimistic updates from response data when reads are very expensive and immediate feedback matters.
- Central coordination in the Background context gathers data rules in one place, but a Service Worker can restart, so long-lived state must not live only in global memory.
When not to use
- Do not wrap function calls inside the same execution context in RPC. It only adds serialization and error layers.
- Do not introduce a general framework or code generator for one simple one-off message before confirming that a small schema and router are not enough.
- Do not copy large binary payloads or high-frequency streams through ordinary messages every time. Measure platform limits and cost, then use a dedicated channel or storage reference when needed.
Common mistakes
- Assuming TypeScript types also provide runtime validation. Values arriving from another execution context are not protected by build-time types.
- Treating message delivery as proof that the handler succeeded.
- Returning exception objects or non-serializable values directly in responses.
- Merging command responses and whole-screen broadcasts so screens that did not make the request overwrite their state directly.
- Using Background Service Worker globals as persistent storage and losing state after restart.
- Invalidating every query for every change event and causing avoidable re-fetch spikes.
Related concepts
- react-query-invalidate-vs-staletime — how a screen can refresh related reads immediately after receiving an external write notification