Building a Browser Extension from Scratch
A step-by-step tutorial for a Chrome/Firefox extension in TypeScript: the manifest, content scripts, background service workers, popup UI and messaging.

Browser extensions are small programs that modify the browser's behavior. They can inject UI into web pages, intercept network requests, modify the DOM, and interact with browser APIs — tabs, bookmarks, storage, notifications. If you have used an ad blocker, a password manager, or a developer tool like React DevTools, you have used a browser extension.
This tutorial builds a practical extension from scratch: a page reading time estimator that shows how long it will take to read any web page.
The Manifest File
Every extension starts with manifest.json. It declares the extension's name, permissions, and which scripts run where.
{
"manifest_version": 3,
"name": "Reading Time Estimator",
"version": "1.0.0",
"description": "Shows estimated reading time for any web page",
"permissions": [
"activeTab",
"storage"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content-script.js"],
"css": ["content-style.css"],
"run_at": "document_idle"
}
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}The three execution contexts — content script, popup, and background service worker — run in isolation. They communicate through the messaging API.
Content Script: Analyzing the Page
The content script runs in the context of every web page the user visits. It has access to the DOM but not to the page's JavaScript variables or the extension's background APIs.
// content-script.ts — Injected into every web page
interface ReadingAnalysis {
wordCount: number;
readingTimeMinutes: number;
headingCount: number;
imageCount: number;
codeBlockCount: number;
}
function analyzePageContent(): ReadingAnalysis {
// Get the main content area — prefer <article> or <main> over <body>
const contentElement =
document.querySelector('article') ??
document.querySelector('main') ??
document.querySelector('[role="main"]') ??
document.body;
// Extract visible text content
const textContent = contentElement.innerText;
const words = textContent.trim().split(/\s+/).filter(w => w.length > 0);
const wordCount = words.length;
// Average reading speed: 238 words per minute (research-based)
const wordsPerMinute = 238;
// Add time for images: ~12 seconds for the first, decreasing after
const images = contentElement.querySelectorAll('img');
const imageTime = Array.from(images).reduce((total, _, index) => {
return total + Math.max(12 - index, 3); // Min 3 seconds per image
}, 0) / 60; // Convert to minutes
// Add time for code blocks: readers slow down for code
const codeBlocks = contentElement.querySelectorAll('pre code, pre');
const codeTime = codeBlocks.length * 0.5; // ~30 seconds per block
const readingTimeMinutes = Math.ceil(
(wordCount / wordsPerMinute) + imageTime + codeTime
);
return {
wordCount,
readingTimeMinutes,
headingCount: contentElement.querySelectorAll('h1, h2, h3').length,
imageCount: images.length,
codeBlockCount: codeBlocks.length,
};
}
// Inject the reading time badge into the page
function injectBadge(analysis: ReadingAnalysis): void {
// Don't inject on pages with very little content
if (analysis.wordCount < 100) return;
const badge = document.createElement('div');
badge.id = 'reading-time-badge';
badge.innerHTML = `
<span class="reading-time-icon">📖</span>
<span class="reading-time-text">${analysis.readingTimeMinutes} min read</span>
<span class="reading-time-words">${analysis.wordCount.toLocaleString()} words</span>
`;
document.body.appendChild(badge);
}
// Run analysis when the page is ready
const analysis = analyzePageContent();
injectBadge(analysis);
// Listen for messages from the popup
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'GET_ANALYSIS') {
sendResponse(analysis);
}
return true; // Keep the message channel open for async response
});Background Service Worker
The background service worker handles events and coordinates between different parts of the extension. In Manifest V3, it is an event-driven service worker that can be terminated when idle.
// background.ts — Runs as a service worker
interface PageStats {
url: string;
readingTime: number;
visitedAt: number;
}
// Track reading statistics across pages
chrome.runtime.onMessage.addListener(
(message, sender, sendResponse) => {
if (message.type === 'SAVE_STATS') {
savePageStats(message.stats).then(() => {
sendResponse({ success: true });
});
return true; // Indicates async response
}
if (message.type === 'GET_HISTORY') {
getReadingHistory().then((history) => {
sendResponse({ history });
});
return true;
}
}
);
async function savePageStats(stats: PageStats): Promise<void> {
const { readingHistory = [] } = await chrome.storage.local.get('readingHistory');
readingHistory.push(stats);
// Keep only the last 100 entries
const trimmed = readingHistory.slice(-100);
await chrome.storage.local.set({ readingHistory: trimmed });
}
async function getReadingHistory(): Promise<PageStats[]> {
const { readingHistory = [] } = await chrome.storage.local.get('readingHistory');
return readingHistory;
}
// Update the extension badge with reading time for the active tab
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
if (changeInfo.status === 'complete') {
try {
const response = await chrome.tabs.sendMessage(tabId, {
type: 'GET_ANALYSIS',
});
if (response?.readingTimeMinutes) {
await chrome.action.setBadgeText({
text: `${response.readingTimeMinutes}m`,
tabId,
});
await chrome.action.setBadgeBackgroundColor({
color: '#2563eb',
tabId,
});
}
} catch {
// Content script not loaded on this page (chrome://, etc.)
}
}
});Popup UI
The popup is a small HTML page that opens when the user clicks the extension icon. It communicates with the content script through the messaging API.
// popup.ts — Logic for the popup UI
document.addEventListener('DOMContentLoaded', async () => {
const timeEl = document.getElementById('reading-time')!;
const wordsEl = document.getElementById('word-count')!;
const detailsEl = document.getElementById('details')!;
try {
// Get the active tab
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
if (!tab.id) {
timeEl.textContent = 'No active tab';
return;
}
// Send a message to the content script in the active tab
const analysis = await chrome.tabs.sendMessage(tab.id, {
type: 'GET_ANALYSIS',
});
if (analysis) {
timeEl.textContent = `${analysis.readingTimeMinutes} min read`;
wordsEl.textContent = `${analysis.wordCount.toLocaleString()} words`;
detailsEl.innerHTML = `
<div class="detail-row">
<span>Headings</span>
<span>${analysis.headingCount}</span>
</div>
<div class="detail-row">
<span>Images</span>
<span>${analysis.imageCount}</span>
</div>
<div class="detail-row">
<span>Code blocks</span>
<span>${analysis.codeBlockCount}</span>
</div>
`;
}
} catch (error) {
timeEl.textContent = 'Cannot analyze this page';
detailsEl.textContent = 'Extension does not have access to this page.';
}
});Messaging Between Contexts
The three contexts — content script, popup, and background — are isolated and communicate exclusively through the Chrome messaging API.
// ❌ Trying to access content script variables from the popup
// This does NOT work — different execution contexts
function popup() {
// Cannot access `analysis` variable from content-script.ts
// const data = analysis; // ReferenceError!
}
// ✅ Using the messaging API to communicate between contexts
// Content script → Background
chrome.runtime.sendMessage({ type: 'SAVE_STATS', stats: pageStats });
// Background → Content script (in a specific tab)
chrome.tabs.sendMessage(tabId, { type: 'GET_ANALYSIS' });
// Popup → Content script (active tab)
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const response = await chrome.tabs.sendMessage(tab.id!, { type: 'GET_ANALYSIS' });
// Type-safe message handling
type ExtensionMessage =
| { type: 'GET_ANALYSIS' }
| { type: 'SAVE_STATS'; stats: PageStats }
| { type: 'GET_HISTORY' };
type ExtensionResponse =
| ReadingAnalysis
| { success: boolean }
| { history: PageStats[] };Build Setup with TypeScript
Extensions need a build step to compile TypeScript and bundle modules. A minimal Webpack or esbuild config handles this.
// build.mjs — esbuild config for the extension
import { build } from 'esbuild';
const shared = {
bundle: true,
minify: process.env.NODE_ENV === 'production',
sourcemap: process.env.NODE_ENV !== 'production',
target: 'chrome100',
};
// Build each entry point separately — they run in different contexts
await Promise.all([
build({
...shared,
entryPoints: ['src/content-script.ts'],
outfile: 'dist/content-script.js',
}),
build({
...shared,
entryPoints: ['src/background.ts'],
outfile: 'dist/background.js',
format: 'esm',
}),
build({
...shared,
entryPoints: ['src/popup.ts'],
outfile: 'dist/popup.js',
}),
]);
console.log('Extension built successfully');Key Takeaways
- Three execution contexts — content scripts access the DOM, background service workers handle events, and popups provide UI; they communicate through messaging
- Manifest V3 uses service workers — no persistent background pages; design for event-driven architecture where the background worker can be terminated
- Content scripts are sandboxed — they can read and modify the DOM but cannot access the page's JavaScript variables or the extension's APIs directly
- Use typed messages — define a union type for all messages to prevent miscommunication between contexts
- Request minimal permissions —
activeTabis safer than<all_urls>for permissions; users trust extensions with fewer permissions - Build each entry point separately — content script, background, and popup are different bundles that run in isolated environments


