A browser extension is not one JavaScript environment — it is several. The popup, the background service worker, the content script, and the dashboard (if you have one) each run in completely isolated JavaScript contexts. They cannot call each other’s functions directly. The only way they communicate is by passing messages through the browser’s runtime messaging API.
In most codebases, this produces one of the worst maintenance problems in extension development: an untyped message bus where every message is { type: string, payload: any }, handler matches are string comparisons, and there is nothing to tell you when a message type has been renamed or a payload field has changed.
There is a better pattern.
The Default Pattern and Why It Fails
The standard approach most tutorials show:
// Sender (popup)
chrome.runtime.sendMessage({ type: 'GET_USER', userId: '123' })
// Receiver (background)
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'GET_USER') {
// message.userId could be anything — TypeScript has no idea
fetchUser(message.userId)
}
})
This compiles. It may even work. But:
- Rename
userIdtoidin the sender without updating the receiver: TypeScript is silent, runtime silently breaks - Add a new required field to a message type: TypeScript cannot warn every call site that they are missing it
- You want to know all the places
GET_USERis sent:grepis your only option, and it will miss renamed variables - The response shape is
any: no autocomplete, no safety
As your extension grows, this pattern becomes a source of subtle bugs that only surface in production.
The RuntimeMessageMap Pattern
The solution is a single TypeScript type that defines every message in your system — its name, payload shape, and response shape — and utility types that use this map to enforce correctness on every send and receive call.
// In @repo/core/src/messages.ts
export interface RuntimeMessageMap {
GET_USER: {
payload: { userId: string }
response: { user: User } | { error: string }
}
CREATE_CHECKOUT_SESSION: {
payload: { priceId: string }
response: { sessionUrl: string } | { error: string }
}
UPDATE_SETTINGS: {
payload: { theme: 'light' | 'dark'; notifications: boolean }
response: { success: true }
}
}
Then typed wrappers for send and listen:
// Typed send
export async function sendMessage<K extends keyof RuntimeMessageMap>(
type: K,
payload: RuntimeMessageMap[K]['payload']
): Promise<RuntimeMessageMap[K]['response']> {
return chrome.runtime.sendMessage({ type, payload })
}
// Typed listener
export function onMessage<K extends keyof RuntimeMessageMap>(
type: K,
handler: (
payload: RuntimeMessageMap[K]['payload'],
sendResponse: (response: RuntimeMessageMap[K]['response']) => void
) => void
) {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === type) {
handler(message.payload, sendResponse)
return true // keep channel open for async response
}
})
}
Now every message call site is fully typed:
// Popup — TypeScript knows priceId is required, and that the response has sessionUrl
const result = await sendMessage('CREATE_CHECKOUT_SESSION', { priceId: 'price_xxx' })
if ('sessionUrl' in result) {
chrome.tabs.create({ url: result.sessionUrl })
}
// Background — TypeScript knows payload is { priceId: string }
onMessage('CREATE_CHECKOUT_SESSION', async (payload, sendResponse) => {
const session = await createStripeSession(payload.priceId)
sendResponse({ sessionUrl: session.url })
})
Rename priceId to price_id in the RuntimeMessageMap without updating the popup: TypeScript error at the call site immediately. Change the response shape: every handler and every read is surfaced. Add a new message type: the definition is the single source of truth.
Content Script Messaging
Messaging from a content script to the background uses the same chrome.runtime.sendMessage / chrome.runtime.onMessage API. The typed wrappers work identically.
Messaging to a content script is slightly different — the background needs to know which tab to target:
export async function sendMessageToTab<K extends keyof RuntimeMessageMap>(
tabId: number,
type: K,
payload: RuntimeMessageMap[K]['payload']
): Promise<RuntimeMessageMap[K]['response']> {
return chrome.tabs.sendMessage(tabId, { type, payload })
}
This goes in the same message utilities package. Content scripts listen with the same onMessage helper.
Payload vs. Request — A Naming Convention That Matters
LightningAddon uses payload and response as the keys inside each message definition. Some patterns use request and response. The payload naming is deliberate: a message type maps to one outbound payload and one inbound response. “Request” implies a request-response cycle, which is only one of the message patterns extensions use — fire-and-forget notifications also exist, and they have no “response.”
Using payload for the outbound data generalizes cleanly to both patterns. Messages with no meaningful response type can define response: void in the map.
Extending the Map
Adding a new message type is one place to edit:
export interface RuntimeMessageMap {
// existing messages...
SYNC_PAGE_CONTENT: {
payload: { url: string; title: string; selection: string | null }
response: { synced: true } | { error: string }
}
}
TypeScript will immediately enforce the new type everywhere SYNC_PAGE_CONTENT is sent or handled. There is nowhere to forget an update.
What LightningAddon Ships
The RuntimeMessageMap in @repo/core ships with the Stripe billing messages (CREATE_CHECKOUT_SESSION, CREATE_PORTAL_SESSION) and the auth messages already defined and implemented. The typed sendMessage, onMessage, and sendMessageToTab wrappers are in @repo/browser-utils.
When you add your own features, you add to the RuntimeMessageMap. Your IDE’s autocomplete will surface every message type and payload field as you type. TypeScript will catch every mismatch before it reaches runtime.
The untyped message bus is one of the most common sources of silent bugs in extension codebases. Eliminating it is a decision you make once, at the start of the project, by defining the map. After that it is invisible — it just works.
Jenny Wilson