Chrome Extension Message Passing with TypeScript: The Complete Guide
Type your messages first — the rest of the architecture falls into place.
Chrome Extension Message Passing with TypeScript: The Complete Guide
Message passing is the concept that trips up every Chrome extension beginner. Your popup, service worker, and content scripts run in separate contexts — they can't share variables or call each other's functions. The only way they communicate is by sending messages.
Without types, this turns into a mess of any-typed objects and runtime crashes. This guide shows you how to build a fully typed message system using TypeScript discriminated unions.
Prerequisite: This assumes you have a working TypeScript extension setup. If not, start with Build Your First Chrome Extension with TypeScript from Scratch.
The Three Message Channels
There are two APIs and three communication patterns:
chrome.runtime.sendMessage — Sends a message to the service worker (background). Used by the popup and content scripts.
chrome.tabs.sendMessage — Sends a message from the service worker to a specific content script (identified by tab ID).
chrome.runtime.onMessage — Listens for incoming messages. Used by any component.
The flows:
Popup ──runtime.sendMessage──► Service Worker
Content Script ──runtime.sendMessage──► Service Worker
Service Worker ──tabs.sendMessage──► Content Script (specific tab)
The popup and content scripts never message each other directly. The service worker is always the intermediary. If you haven't read How Chrome Extensions Actually Work in 2026, that post covers why this architecture exists.
The Problem Without Types
Here's how most tutorials teach message passing:
// popup.ts — sending
chrome.runtime.sendMessage({ type: "GET_DATA" }, (response) => {
console.log(response.data); // What's in response? Who knows.
});
// background.ts — receiving
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "GET_DATA") {
sendResponse({ data: "something" });
}
// Did I forget a message type? TypeScript can't tell me.
});
This compiles. It also breaks silently when you rename a message type, add a field, or forget to handle a new case.
Step 1: Define Your Message Types
Create a shared types file that both sender and receiver import:
// src/types/messages.ts
// Each message type is a separate interface
interface GetTabInfoMessage {
type: "GET_TAB_INFO";
}
interface SaveSettingsMessage {
type: "SAVE_SETTINGS";
payload: {
blockedSites: string[];
challengeLevel: "easy" | "medium" | "hard";
};
}
interface InjectContentMessage {
type: "INJECT_CONTENT";
payload: {
tabId: number;
cssSelector: string;
};
}
// Discriminated union — TypeScript can narrow on `type`
export type ExtensionMessage =
| GetTabInfoMessage
| SaveSettingsMessage
| InjectContentMessage;
// Response types — one per message type
export type MessageResponse<T extends ExtensionMessage["type"]> =
T extends "GET_TAB_INFO"
? { title: string; url: string }
: T extends "SAVE_SETTINGS"
? { success: boolean }
: T extends "INJECT_CONTENT"
? { injected: boolean }
: never;
The type field is the discriminant. When you switch on message.type, TypeScript narrows the union and knows exactly what payload looks like.
Step 2: Type-Safe Send Function
Wrap chrome.runtime.sendMessage in a generic function:
// src/utils/messaging.ts
import type { ExtensionMessage, MessageResponse } from "../types/messages";
export function sendMessage<T extends ExtensionMessage["type"]>(
message: Extract<ExtensionMessage, { type: T }>
): Promise<MessageResponse<T>> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(message, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(response as MessageResponse<T>);
});
});
}
Usage in the popup:
// src/popup.ts
import { sendMessage } from "./utils/messaging";
async function loadTabInfo() {
// TypeScript knows the response is { title: string; url: string }
const info = await sendMessage({ type: "GET_TAB_INFO" });
document.getElementById("title")!.textContent = info.title;
}
async function saveSettings() {
// TypeScript enforces the payload shape
const result = await sendMessage({
type: "SAVE_SETTINGS",
payload: {
blockedSites: ["reddit.com", "twitter.com"],
challengeLevel: "medium",
},
});
console.log("Saved:", result.success);
}
If you pass the wrong payload shape, TypeScript catches it at compile time. If you access a field that doesn't exist on the response, TypeScript catches that too.
Step 3: Type-Safe Listener
The service worker listener:
// src/background.ts
import type { ExtensionMessage, MessageResponse } from "./types/messages";
chrome.runtime.onMessage.addListener(
(
message: ExtensionMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response: MessageResponse<ExtensionMessage["type"]>) => void
): boolean | undefined => {
switch (message.type) {
case "GET_TAB_INFO": {
// Handle async — MUST return true
chrome.tabs
.query({ active: true, currentWindow: true })
.then(([tab]) => {
sendResponse({
title: tab.title ?? "Unknown",
url: tab.url ?? "Unknown",
});
});
return true; // <-- keeps the message channel open
}
case "SAVE_SETTINGS": {
// message.payload is typed as { blockedSites: string[], challengeLevel: ... }
chrome.storage.sync.set(message.payload).then(() => {
sendResponse({ success: true });
});
return true;
}
case "INJECT_CONTENT": {
// Handle content injection
sendResponse({ injected: true });
return true;
}
}
}
);
The return true Gotcha
This is the single most common bug in Chrome extension development.
chrome.runtime.onMessage.addListener expects a synchronous return value. If you return undefined (the default), Chrome closes the message channel immediately. Your async sendResponse call arrives too late — the sender gets undefined.
The rule: If your listener does anything asynchronous before calling sendResponse, you must return true from the listener. This tells Chrome: "I'll call sendResponse later — keep the channel open."
// BAD — sendResponse called after channel closes
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
fetch("https://api.example.com/data")
.then((res) => res.json())
.then((data) => sendResponse(data)); // Too late, channel is closed
// implicitly returns undefined
});
// GOOD — return true keeps the channel open
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
fetch("https://api.example.com/data")
.then((res) => res.json())
.then((data) => sendResponse(data));
return true; // Channel stays open
});
Forgetting return true produces no errors, no warnings — just silently broken communication. If your messages "randomly stop working," this is almost always why.
Sending Messages to Content Scripts
Messages from the service worker to a content script use chrome.tabs.sendMessage and require the tab ID:
// src/background.ts
async function notifyContentScript(tabId: number, data: unknown) {
try {
const response = await chrome.tabs.sendMessage(tabId, {
type: "UPDATE_UI",
payload: data,
});
console.log("Content script responded:", response);
} catch (error) {
// Content script might not be injected in this tab
console.warn("No content script in tab", tabId);
}
}
The content script listens with the same chrome.runtime.onMessage.addListener. The API is the same — only the sending direction changes.
When to Use chrome.runtime.connect Instead
sendMessage is fire-and-forget with a single response. For ongoing communication — like streaming data or keeping a live connection — use chrome.runtime.connect to open a port:
// popup.ts — open a long-lived connection
const port = chrome.runtime.connect({ name: "popup-channel" });
port.onMessage.addListener((message) => {
console.log("Received:", message);
});
port.postMessage({ type: "SUBSCRIBE_UPDATES" });
Use sendMessage for request/response. Use connect for streams or bidirectional channels. For most extensions, sendMessage is all you need.
Debugging Messages
Open chrome://extensions, find your extension, and click "Inspect views: service worker" to see the service worker's console. The popup has its own DevTools — right-click the popup and choose "Inspect." Content scripts log to the web page's console.
When a message doesn't arrive, check:
- Is the listener registered before the message is sent?
- Did you
return truefor async responses? - Is the content script actually injected in the target tab?
- Check
chrome.runtime.lastErrorin the sender's callback.
What's Next
With typed message passing in place, your extension components can communicate safely. The next challenges:
- Persisting state — Your service worker terminates when idle. Any data in memory dies with it. How to Persist State in a Chrome Extension covers typed
chrome.storagewrappers that keep your data alive. - Building the UI layer — Once your popup needs more than basic HTML, Building a Chrome Extension Popup with React and TypeScript shows when React is worth the bundle size.
- Security model — Message passing interacts with permissions. Chrome Extension Permissions explains what your content scripts and service workers are allowed to do.
This is part of the Chrome Extensions from Zero to Product series, where I walk through everything I learned building ResistGate and Amethyst — from first popup to published product.
Building in public
Follow the journey
as I build AI tools, products, and a serious founder life.
No spam. Unsubscribe anytime.