A Practical Guide to Chrome Extension Development
Chrome extensions are one of the most underrated platforms for indie developers. Think about it: the Chrome Web Store has hundreds of millions of users, the barrier to entry is low, and you can build something genuinely useful with nothing more than HTML, CSS, and JavaScript. When I built A11yMate — a WCAG accessibility checker — as a Chrome extension, I discovered a distribution channel that made getting my tool in front of real users dramatically easier than any other platform.
This guide covers everything you need to know to build and publish a Chrome extension using Manifest V3, the current standard. I will walk through real patterns and problems, not abstract theory.
Understanding Manifest V3
Manifest V3 (MV3) is the current extension platform for Chrome. If you have seen older tutorials referencing Manifest V2, be aware that MV2 is deprecated and new extensions must use MV3. The most significant changes are the replacement of persistent background pages with service workers, a new declarative approach to network request modification, and stricter content security policies.
Every extension starts with a manifest.json file. Here is a minimal example:
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A brief description of what it does.",
"permissions": ["storage", "activeTab"],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"css": ["content.css"]
}
]
}
Let me break down the key components.
The Architecture: Three Contexts
A Chrome extension has up to three execution contexts, and understanding the boundaries between them is essential.
1. The Popup
The popup is the small UI window that appears when a user clicks your extension icon in the toolbar. It is an HTML page, so you can build it with any frontend technology — React, Vue, Svelte, or plain HTML and JavaScript.
<!-- popup.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div id="app">
<h1>My Extension</h1>
<button id="scan-btn">Scan Page</button>
<div id="results"></div>
</div>
<script src="popup.js"></script>
</body>
</html>
Important behavior to know: the popup's lifecycle is short. It is created when the user opens it and destroyed when they close it (by clicking elsewhere). Any state you need to persist must be saved to chrome.storage or communicated to the background service worker.
2. The Background Service Worker
The service worker runs in the background and handles events. In MV3, this is a service worker, not a persistent background page. This is the biggest mental model shift from MV2.
// background.js
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
// First install — set defaults
chrome.storage.local.set({
settings: { theme: 'light', autoScan: false }
});
}
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'ANALYZE_PAGE') {
performAnalysis(message.data)
.then(results => sendResponse({ success: true, results }))
.catch(error => sendResponse({ success: false, error: error.message }));
return true; // Required for async sendResponse
}
});
The critical thing about service workers: they can be terminated at any time. Chrome will shut them down after about 30 seconds of inactivity. This means you cannot store state in global variables and expect it to persist. Use chrome.storage for anything that needs to survive a service worker restart.
3. Content Scripts
Content scripts run in the context of web pages. They can read and modify the DOM of the page the user is viewing. However, they run in an isolated world — they share the DOM but not the JavaScript environment of the page.
// content.js
function analyzePageAccessibility() {
const images = document.querySelectorAll('img');
const issues = [];
images.forEach(img => {
if (!img.hasAttribute('alt')) {
issues.push({
type: 'missing-alt',
element: img.outerHTML.substring(0, 100),
severity: 'error'
});
}
});
return issues;
}
// Listen for messages from popup or background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'SCAN_PAGE') {
const issues = analyzePageAccessibility();
sendResponse({ issues });
}
});
Content scripts can access a limited set of Chrome APIs directly (chrome.runtime, chrome.storage, chrome.i18n). For anything else, they need to send a message to the background service worker.
Messaging Between Contexts
Communication between these three contexts happens through Chrome's messaging API. This is where most beginners struggle, so let me be specific about the patterns.
Popup to Content Script:
// In popup.js
async function scanCurrentPage() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const response = await chrome.tabs.sendMessage(tab.id, { type: 'SCAN_PAGE' });
displayResults(response.issues);
}
Content Script to Background:
// In content.js
chrome.runtime.sendMessage(
{ type: 'SAVE_RESULTS', data: scanResults },
(response) => {
if (response.success) {
showNotification('Results saved');
}
}
);
Background to Content Script:
// In background.js
chrome.tabs.sendMessage(tabId, { type: 'HIGHLIGHT_ISSUES', issues: filteredIssues });
A common gotcha: chrome.tabs.sendMessage will fail if there is no content script running on that tab. This happens when the extension was installed or updated after the tab was already open. Handle this gracefully:
try {
const response = await chrome.tabs.sendMessage(tab.id, { type: 'SCAN_PAGE' });
displayResults(response.issues);
} catch (error) {
// Content script not loaded — inject it first or ask user to refresh
showMessage('Please refresh the page and try again.');
}
The Storage API
chrome.storage is your primary persistence layer. It comes in three flavors:
chrome.storage.local: Stores data locally. Up to 10MB by default (can requestunlimitedStorage).chrome.storage.sync: Syncs data across the user's Chrome instances. Limited to 100KB total.chrome.storage.session: Available only to the service worker and content scripts. Cleared when the browser closes.
// Writing data
await chrome.storage.local.set({
scanHistory: updatedHistory,
lastScanDate: Date.now()
});
// Reading data
const { scanHistory, lastScanDate } = await chrome.storage.local.get([
'scanHistory',
'lastScanDate'
]);
// Listening for changes (useful for reactive UI)
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes.scanHistory) {
updateHistoryUI(changes.scanHistory.newValue);
}
});
Use storage.sync for user preferences and settings — things that are small and benefit from being available on all their devices. Use storage.local for everything else.
Permissions: Request Only What You Need
Permissions affect user trust. Every permission you request is shown to the user during installation, and excessive permissions drive people away.
Common permissions and when you need them:
"activeTab": Access to the currently active tab when the user invokes the extension. This is the least-scary permission and should be your default choice."storage": Access tochrome.storage. Almost every extension needs this."tabs": Access to tab URLs and titles. Only request this if you need to read tab information without the user explicitly invoking the extension."scripting": Ability to inject scripts programmatically. Needed if you usechrome.scripting.executeScript()instead of static content scripts.
Host permissions (like "https://*/*") are particularly sensitive because they mean your extension can access the user's data on those sites. Use the narrowest match patterns possible.
{
"host_permissions": [
"https://api.myservice.com/*"
]
}
If you need broad access but only when the user triggers it, prefer "activeTab" over "<all_urls>". The activeTab permission grants temporary access to the current tab when the user clicks your extension icon, which feels much less invasive.
Building with Modern Tooling
For simple extensions, you can work with plain HTML and JavaScript files. But as your extension grows, you will want a proper build system. Here is my recommendation:
Vite with CRXJS is the best developer experience I have found for extension development. CRXJS is a Vite plugin that understands the Chrome extension manifest and handles all the bundling complexity for you:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import crx from '@crxjs/vite-plugin';
import manifest from './manifest.json';
export default defineConfig({
plugins: [
react(),
crx({ manifest })
]
});
With this setup, you get hot module replacement in your popup and options pages, automatic manifest processing, and a standard Vite build pipeline for production. It dramatically speeds up the development cycle.
Debugging Tips
Chrome provides excellent debugging tools for extensions, but you need to know where to find them.
For the popup: Right-click the popup and select "Inspect." This opens DevTools for the popup's context.
For the service worker: Go to chrome://extensions, find your extension, and click "Inspect views: service worker." This opens DevTools for the background script.
For content scripts: Open DevTools on any page, go to the Sources tab, and look for your extension under the "Content scripts" section in the file tree.
Common debugging scenarios:
If your content script is not running, check the matches pattern in your manifest. A common mistake is forgetting that <all_urls> requires host permissions in MV3.
If your service worker seems to lose state, remember that it gets terminated. Add logging to chrome.runtime.onStartup and chrome.runtime.onInstalled to track its lifecycle.
If messaging fails silently, check that the receiving end has a listener registered. In MV3, the service worker might not be running when a message is sent. Use chrome.runtime.sendMessage with error checking.
Publishing to the Chrome Web Store
Once your extension is ready, publishing involves these steps:
- Create a developer account at the Chrome Web Store Developer Dashboard. There is a one-time $5 registration fee.
- Prepare your assets: You need a 128x128 icon, at least one 1280x800 screenshot, a detailed description, and optionally a promotional tile image.
- Package your extension: Zip the contents of your build directory (not the directory itself — the files inside it).
- Upload and fill in the listing details. Write a thorough description. The review team reads this.
- Submit for review. First reviews typically take 1-3 business days. Updates are usually faster.
Tips for passing review smoothly:
- Your privacy policy must be accurate and accessible. If you collect any user data, even locally, disclose it.
- Request only the permissions you actively use. Unused permissions are a red flag for reviewers.
- Make sure your extension does what the description says. Reviewers test it.
- Do not include obfuscated code. The review team needs to be able to read your source.
What Makes a Successful Extension
After building and maintaining extensions, here is what I have learned about what works:
Solve one problem well. The most successful extensions are focused. They do one thing and they do it better than the alternatives. Resist the urge to become a Swiss Army knife.
Respect the user's resources. Extensions run in the user's browser. Do not consume excessive memory, do not make unnecessary network requests, and do not slow down page loads. Profile your content scripts and optimize aggressively.
Communicate clearly. Your popup should explain what the extension does and what actions are available. Do not assume users will read your documentation — most will not.
Update regularly. Chrome's platform evolves, and extensions that are not maintained eventually break. Set up automated testing and a regular update schedule.
Chrome extensions are a fantastic platform for indie developers. The combination of low cost, built-in distribution, and the ability to enhance the browsing experience for millions of people makes them worth learning. Start small, ship often, and listen to your users.