TypeScript Messaging Across Extension Contexts: The RuntimeMessageMap Pattern

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 completel

  • Jenny Wilson Jenny Wilson
  • date icon

    Friday, Mar 13, 2026

TypeScript Messaging Across Extension Contexts: The RuntimeMessageMap Pattern

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 userId to id in 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_USER is sent: grep is 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.

Blog

Read More Posts

Your Trusted Partner in Data Protection with Cutting-Edge Solutions for
Comprehensive Data Security.

How to Get Your First 100 Paying Users for a Browser Extension
date icon

Tuesday, Mar 17, 2026

How to Get Your First 100 Paying Users for a Browser Extension

Most browser extension SaaS products never reach 100 paying users — not because they are bad products, but because the d

Read More
Supabase vs Firebase for Your Extension Backend: A Practical 2026 Comparison
date icon

Sunday, Mar 15, 2026

Supabase vs Firebase for Your Extension Backend: A Practical 2026 Comparison

If you are starting a browser extension SaaS, you need a backend for auth, data storage, and Stripe webhook handling. Su

Read More
How to Update Your Extension Without Breaking Existing Users
date icon

Saturday, Mar 14, 2026

How to Update Your Extension Without Breaking Existing Users

Browser extensions store data in chrome.storage.local and chrome.storage.sync. Unlike a server-side database, you do

Read More
LightningAddon Chrome, Firefox & Safari extension framework call to action

Stop Rebuilding the Same Foundation. Start Shipping.

Auth, billing, multi-browser builds, typed messaging — every extension SaaS needs the same boring foundation. LightningAddon ships all of it, battle-tested and ready in 15 minutes. Pay once, own it forever.

Get LightningAddon