Building Browser Extensions with TypeScript: Content Scripts, Service Workers, and Cross-Browser Distribution
A production-focused guide to building browser extensions with TypeScript and Manifest V3. Covers architecture, background service workers, content script injection, cross-context messaging, storage APIs, cross-browser compatibility, and the review process for Chrome and Firefox.
Browser extensions sit in a peculiar space. They run JavaScript with elevated trust, but inside a sandbox with strict rules. They interact with web pages you didn’t author, communicate across isolated execution contexts, and must survive browser restarts without persistent memory. If you approach extension development with the same mental model as a regular web app, you will hit confusing walls quickly.
This article walks through building a production-quality extension using TypeScript and Manifest V3 (MV3). The goal is to give you a working mental model of the architecture, show real patterns for the tricky parts (messaging, storage, content script injection), and cover cross-browser compatibility without pretending it’s seamless.
The Architecture You’re Actually Working With
A browser extension is not one program. It’s a collection of isolated JavaScript contexts that communicate through a message-passing API. Each context has different capabilities and different lifetimes.
Background service worker (MV3 replacement for background pages): runs in the extension’s background process, has no DOM access, can use most Chrome/WebExtensions APIs, but is ephemeral. It terminates when idle and wakes on events. This is the single biggest architectural shift from Manifest V2.
Content scripts: injected into web pages, share the page’s DOM but run in an isolated JavaScript context. They cannot access the page’s JS variables, and the page cannot access theirs. They have a limited subset of extension APIs available.
Popup UI: the HTML page that renders when a user clicks your extension icon. Has full extension API access but is destroyed every time the popup closes.
Options page: a full HTML page for settings, loaded in a tab or in the extensions management UI. Behaves like a normal web page with extension API access.
Side panel (Chrome 114+): persistent panel alongside the main content. Less common but useful for extensions requiring ongoing interaction.
The communication flow is: content scripts talk to the service worker via chrome.runtime.sendMessage. The popup and options page also talk to the service worker the same way. The service worker can inject scripts, manage storage, make background requests, and coordinate state.
Project Setup with TypeScript and Vite
Webpack has been the standard for extension bundling, but Vite with the vite-plugin-web-extension or @crxjs/vite-plugin makes the development loop significantly faster.
src/
background/
index.ts # service worker entry
content/
index.ts # content script entry
popup/
index.html
index.ts
options/
index.html
index.ts
shared/
types.ts # shared message types
storage.ts # typed storage helpers
manifest.json
vite.config.ts
tsconfig.json
A minimal tsconfig.json for extension work:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"lib": ["ES2022", "DOM"],
"types": ["chrome"]
}
}
Install @types/chrome for Chrome extension type definitions. For Firefox/cross-browser, webextension-polyfill and @types/webextension-polyfill give you a Promise-based API surface that works across browsers.
// manifest.json (MV3)
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"permissions": ["storage", "activeTab", "scripting"],
"host_permissions": ["https://example.com/*"],
"background": {
"service_worker": "dist/background/index.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://example.com/*"],
"js": ["dist/content/index.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup/index.html"
}
}
Typed Messaging Between Contexts
The biggest footgun in extension development is untyped message passing. You end up with strings as message types and any payloads throughout. Define a discriminated union for your messages upfront.
// shared/types.ts
export type ExtensionMessage =
| { type: "GET_PAGE_DATA"; payload: { url: string } }
| { type: "PAGE_DATA_RESPONSE"; payload: { title: string; meta: Record<string, string> } }
| { type: "TOGGLE_FEATURE"; payload: { feature: string; enabled: boolean } }
| { type: "FEATURE_STATE"; payload: { feature: string; enabled: boolean } };
export type MessageResponse<T extends ExtensionMessage["type"]> = Extract<
ExtensionMessage,
{ type: T }
>["payload"];
In the service worker, handle messages with a typed switch:
// background/index.ts
import type { ExtensionMessage } from "../shared/types";
chrome.runtime.onMessage.addListener(
(message: ExtensionMessage, sender, sendResponse) => {
switch (message.type) {
case "GET_PAGE_DATA": {
handleGetPageData(message.payload, sender)
.then(sendResponse)
.catch((err) => sendResponse({ error: err.message }));
// Return true to indicate async response
return true;
}
case "TOGGLE_FEATURE": {
handleToggleFeature(message.payload).then(sendResponse);
return true;
}
}
}
);
async function handleGetPageData(
payload: { url: string },
sender: chrome.runtime.MessageSender
) {
// Service worker logic here
return { title: "Example", meta: {} };
}
Sending from a content script with type safety:
// content/index.ts
import type { ExtensionMessage, MessageResponse } from "../shared/types";
async function sendMessage<T extends ExtensionMessage["type"]>(
message: Extract<ExtensionMessage, { type: T }>
): Promise<MessageResponse<T>> {
return chrome.runtime.sendMessage(message);
}
// Usage: fully typed, no casting
const response = await sendMessage({
type: "GET_PAGE_DATA",
payload: { url: window.location.href },
});
// response is typed as { title: string; meta: Record<string, string> }
One thing to be explicit about: chrome.runtime.sendMessage returns a Promise in Chrome 99+ but requires the callback pattern in older environments and Firefox without the polyfill. Use webextension-polyfill if you need consistent Promise-based behavior across browsers.
Storage APIs and Typed Wrappers
Extensions have three storage areas: sync (synced across devices, 100KB quota), local (device-only, 10MB quota), and session (in-memory, cleared on browser restart, 10MB). Pick based on what the data is.
A typed storage wrapper prevents the chaos of scattered chrome.storage.local.get calls:
// shared/storage.ts
export interface ExtensionStorage {
featureFlags: Record<string, boolean>;
userPreferences: {
theme: "light" | "dark";
density: "compact" | "comfortable";
};
lastSyncTimestamp: number;
}
const defaults: ExtensionStorage = {
featureFlags: {},
userPreferences: { theme: "light", density: "comfortable" },
lastSyncTimestamp: 0,
};
export async function getStorage<K extends keyof ExtensionStorage>(
key: K
): Promise<ExtensionStorage[K]> {
const result = await chrome.storage.local.get(key);
return (result[key] ?? defaults[key]) as ExtensionStorage[K];
}
export async function setStorage<K extends keyof ExtensionStorage>(
key: K,
value: ExtensionStorage[K]
): Promise<void> {
await chrome.storage.local.set({ [key]: value });
}
// Subscribe to changes for a specific key
export function onStorageChange<K extends keyof ExtensionStorage>(
key: K,
callback: (newValue: ExtensionStorage[K], oldValue: ExtensionStorage[K]) => void
): () => void {
const listener = (changes: Record<string, chrome.storage.StorageChange>) => {
if (key in changes) {
callback(changes[key].newValue, changes[key].oldValue);
}
};
chrome.storage.local.onChanged.addListener(listener);
return () => chrome.storage.local.onChanged.removeListener(listener);
}
Content Script Injection Patterns
Static content script injection (defined in manifest) works for known URLs at install time. For dynamic injection based on user action, use the scripting API:
// background/index.ts
async function injectContentScript(tabId: number): Promise<void> {
await chrome.scripting.executeScript({
target: { tabId },
files: ["dist/content/index.js"],
});
}
// Inject CSS separately to avoid FOUC
async function injectContentStyles(tabId: number): Promise<void> {
await chrome.scripting.insertCSS({
target: { tabId },
files: ["dist/content/styles.css"],
});
}
For content scripts that need to run at document_start (before the page renders), be careful: the DOM is not ready, so defer DOM manipulation to a DOMContentLoaded listener or use run_at: "document_start" only for things like overriding globals or setting up mutation observers.
Programmatic injection requires activeTab or explicit host_permissions. The permissions model matters here: activeTab grants temporary access to the current tab only after user gesture. Broad host permissions (<all_urls>) require justification in the review process and often trigger manual review.
Service Worker Lifecycle: The Real Gotcha
In MV2, background pages persisted indefinitely. MV3 service workers terminate after approximately 30 seconds of inactivity. Any state stored in module-level variables is gone when the service worker restarts. This surprises everyone the first time.
The rules:
- Never use in-memory state as your source of truth. Use
chrome.storage.sessionfor ephemeral cross-request state,chrome.storage.localfor persistent state. - Reconnect to long-lived connections defensively. If you open a
chrome.runtime.connectport, expect it to close. - Use
chrome.alarmsfor recurring work, notsetInterval. Alarms persist across service worker restarts.
// background/index.ts
// Wrong: state lost on service worker restart
let requestCount = 0;
// Right: use storage for any state that must survive
async function incrementRequestCount(): Promise<number> {
const current = await getStorage("requestCount" as any) || 0;
const next = current + 1;
await chrome.storage.session.set({ requestCount: next });
return next;
}
// Use chrome.alarms for periodic tasks
chrome.alarms.create("sync", { periodInMinutes: 5 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "sync") {
performSync();
}
});
// Keep service worker alive during long async operations with a keep-alive ping
// (only use when necessary, browser vendors may deprecate patterns that abuse this)
Long-running operations that exceed 30 seconds need a different approach. Off-screen documents (Chrome 109+) allow you to run a hidden document that can persist and handle tasks like WebSocket connections or audio playback.
Cross-Browser Compatibility
The WebExtensions API is largely compatible across Chrome, Firefox, and Edge. Safari is the outlier, requiring a conversion step via Xcode. The practical breakdown:
| Feature | Chrome (MV3) | Firefox (MV2/MV3) | Safari |
|---|---|---|---|
| Service workers | Native | MV3 experimental | Supported (MV3) |
| Background pages | Removed in MV3 | MV2 only | MV2 only |
chrome.* namespace | Yes | Yes (via polyfill) | Yes |
browser.* namespace | No (use polyfill) | Native | Yes |
| Scripting API | Yes | Yes (FF 98+) | Yes |
| Side panel | Yes (Chrome 114+) | No | No |
| Manifest V3 | Required (Chrome) | Optional | Required |
Firefox still supports MV2 and has been cautious about MV3 adoption due to concerns about declarativeNetRequest limiting ad blockers. If your extension needs webRequest blocking, target Firefox with MV2 while supporting Chrome with MV3.
For a cross-browser build, use webextension-polyfill and build separate manifest files per browser:
// vite.config.ts
import { defineConfig } from "vite";
import webExtension from "vite-plugin-web-extension";
export default defineConfig({
plugins: [
webExtension({
manifest: () =>
process.env.TARGET === "firefox"
? require("./manifest.firefox.json")
: require("./manifest.chrome.json"),
}),
],
});
The webextension-polyfill wraps chrome.* APIs with Promise-based equivalents that work in Firefox’s browser.* namespace, so you write once and it works in both.
Content Security Policy Restrictions
MV3 enforces a strict CSP that blocks inline scripts and eval. If you’re injecting UI into pages (shadow DOM panels, overlays), you cannot use innerHTML to inject event handlers or inline scripts. Everything must go through proper DOM APIs.
// Wrong: blocked by CSP in content scripts creating extension pages
const div = document.createElement("div");
div.innerHTML = '<button onclick="doThing()">Click</button>';
// Right: create elements programmatically
const div = document.createElement("div");
const button = document.createElement("button");
button.textContent = "Click";
button.addEventListener("click", doThing);
div.appendChild(button);
For extension pages (popup, options), your own CSP applies. Don’t add unsafe-inline or unsafe-eval to your manifest’s content_security_policy. If you’re using a bundler with code splitting, make sure dynamic imports resolve to extension-origin URLs, not external ones.
Memory Leaks in Content Scripts
Content scripts inject into pages that users navigate away from. If you attach listeners to window, document, or DOM elements without cleaning up, you create memory leaks that accumulate across tab navigations.
The pattern for safe content script teardown:
// content/index.ts
const cleanupFns: Array<() => void> = [];
function onCleanup(fn: () => void): void {
cleanupFns.push(fn);
}
function teardown(): void {
for (const fn of cleanupFns) {
fn();
}
cleanupFns.length = 0;
}
// Register all listeners through onCleanup
const handleScroll = () => { /* ... */ };
window.addEventListener("scroll", handleScroll);
onCleanup(() => window.removeEventListener("scroll", handleScroll));
// Remove storage listeners
const removeStorageListener = onStorageChange("featureFlags", (flags) => {
updateUI(flags);
});
onCleanup(removeStorageListener);
// Listen for navigation away from the page
window.addEventListener("pagehide", teardown);
onCleanup(() => window.removeEventListener("pagehide", teardown));
MutationObservers are another common leak source. Disconnect them in teardown.
Distribution: Chrome Web Store and Firefox Add-ons
Chrome Web Store review takes 1-7 days for new extensions, faster for updates. The review is automated with a manual pass for anything flagged. Common rejection reasons:
- Requesting permissions not justified by functionality. If you request
historybut don’t use it, you’ll get rejected. Request only what you need, and explain non-obvious permissions in your listing description. - Executing remote code. MV3 prohibits fetching and executing JavaScript from external sources. All logic must be bundled in the extension package.
- Misleading name or description. The store is strict about brand impersonation.
- Obfuscated code. Minification is fine; intentionally obfuscated logic is not.
For the submission package: zip your dist/ directory (not the project root). Make sure source maps are not included in the production build unless you need them for the developer dashboard.
Firefox Add-ons (AMO) requires source code submission for extensions with minified/bundled code. You submit a separate zip of your source (including package.json, package-lock.json, build scripts) so reviewers can reproduce your build. Include a SOURCE_CODE.md with build instructions. This is not optional for popular extensions; Firefox reviewers check that the built output matches what you submitted.
Firefox’s review timeline is slower: 1-4 weeks for new submissions. Use the self-distribution option (signing outside AMO) if you’re distributing to enterprise users or don’t want to wait.
Putting It Together: A Real-World Pattern
A common extension pattern: highlight and save text from any web page to a centralized list, accessible from the popup.
Content script detects selection and sends it to the service worker. The service worker saves it to chrome.storage.local. The popup reads and displays saved items.
// content/index.ts
document.addEventListener("mouseup", async () => {
const selection = window.getSelection()?.toString().trim();
if (!selection || selection.length < 3) return;
await chrome.runtime.sendMessage({
type: "SAVE_SELECTION",
payload: {
text: selection,
url: window.location.href,
title: document.title,
timestamp: Date.now(),
},
});
});
// background/index.ts
interface SavedItem {
text: string;
url: string;
title: string;
timestamp: number;
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === "SAVE_SELECTION") {
chrome.storage.local.get("savedItems", (result) => {
const items: SavedItem[] = result.savedItems ?? [];
items.unshift(message.payload);
// Keep last 500 items
if (items.length > 500) items.splice(500);
chrome.storage.local.set({ savedItems: items }, () => {
sendResponse({ ok: true });
});
});
return true; // async response
}
});
// popup/index.ts
async function loadItems(): Promise<void> {
const result = await chrome.storage.local.get("savedItems");
const items: SavedItem[] = result.savedItems ?? [];
renderItems(items);
}
function renderItems(items: SavedItem[]): void {
const list = document.getElementById("items")!;
list.innerHTML = "";
for (const item of items) {
const li = document.createElement("li");
const text = document.createElement("span");
text.textContent = item.text;
const source = document.createElement("a");
source.href = item.url;
source.textContent = item.title;
source.target = "_blank";
li.appendChild(text);
li.appendChild(source);
list.appendChild(li);
}
}
loadItems();
This covers the full message flow across all three contexts with real data persistence and no in-memory state in the service worker.
Tradeoffs by Approach
| Concern | Static manifest injection | Dynamic scripting API |
|---|---|---|
| Setup complexity | Low | Medium |
| Flexibility | Fixed at install time | Runtime control |
| Permissions required | host_permissions in manifest | scripting permission |
| User trust | Visible in install prompt | Less visible, granted at use |
| CSP review scrutiny | Lower | Higher |
| Storage choice | Quota | Persistence | Use case |
|---|---|---|---|
local | 10MB | Permanent | User data, cache |
sync | 100KB | Synced across devices | Settings |
session | 10MB | Cleared on browser restart | Ephemeral state |
managed | Read-only | Enterprise policy | Enterprise config |
Closing
Extension development rewards careful attention to execution context. The mistakes that cost the most time are the ones rooted in treating the service worker like a persistent Node process, or the content script like a regular web app. The architecture imposes constraints that, once understood, actually make the system more predictable.
Get the TypeScript message contracts right early. Centralize your storage access behind typed wrappers. Treat service worker state as ephemeral by default. Those three habits prevent the majority of bugs you’d otherwise find in production or, worse, in the review queue after a 7-day wait.
The cross-browser story is workable with the polyfill and separate manifests per target. The review process is tedious but navigable if you request only the permissions you use and bundle all logic locally.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.