How to Create a Chrome Extension: Complete Developer Guide 2026
Table of Contents
- Introduction
- Understanding Chrome Extension Architecture
- Setting Up Your Development Environment
- Building Your First Extension Step by Step
- Chrome Extension APIs Deep Dive
- Content Scripts and DOM Manipulation
- Service Workers (Background Scripts) in MV3
- Permissions: Request the Minimum
- Debugging and Testing
- Publishing to Chrome Web Store
- Beyond the Basics: Advanced Patterns
- Frequently Asked Questions
- Conclusion
1. Introduction
I've built 16 Chrome extensions, shipped them to thousands of users, and earned $400K on Upwork building software. Here's everything I know about creating Chrome extensions.
This is not another shallow tutorial that shows you how to change a page's background color. This is the guide I wish I had when I started building my first extension years ago - a complete, practical walkthrough that takes you from zero to a published Chrome Web Store listing, written by someone who does this for a living.
Over the past decade, I've built every kind of Chrome extension you can imagine: tab managers, content analyzers, productivity tools, developer utilities, and SEO helpers. My suite of 16 extensions (the Zovo suite) covers everything from word counting to tab suspension. Each one taught me something new about the platform, and I'm going to compress all of those lessons into this single guide.
Creating a Chrome extension is one of the most leveraged skills a web developer can learn. You already know HTML, CSS, and JavaScript. With those same tools, you can build software that runs inside the browser of over 3 billion Chrome users. No app store gatekeepers delaying you for weeks, no complex native SDKs to learn, no new programming language required.
What you'll learn in this guide:
- How Chrome extensions work under the hood (architecture, manifest, components)
- Setting up a proper development environment
- Building a real, working extension from scratch with complete code
- Every major Chrome API you'll need, with practical examples
- Content scripts, service workers, and message passing
- The permissions model and how to build trust with users
- Debugging techniques that will save you hours
- Publishing to the Chrome Web Store and improving your listing
- Advanced patterns for production-grade extensions
Prerequisites: You need a basic understanding of HTML, CSS, and JavaScript. If you can build a simple web page with some interactivity, you have enough knowledge to create a Chrome extension. If you're comfortable with document.querySelector(), event listeners, and JSON, you're more than ready.
Time estimate: Your first extension in 30 minutes. A polished, publishable extension in a weekend. I'm not exaggerating - the barrier to entry is genuinely low. The depth and polish are where the real work happens, and that's what this guide will help you navigate.
Let's get started.
2. Understanding Chrome Extension Architecture
Before you write a single line of code, you need to understand what a Chrome extension actually is. This foundational knowledge will make every step of creating a Chrome extension easier. Strip away the mystery and a Chrome extension is just a zip file containing web files - HTML, CSS, JavaScript, images - plus a special file called manifest.json that tells Chrome how to use them.
That's it. There's no compilation step, no binary, no special runtime. When you install an extension, Chrome unzips it into a profile directory and loads your files according to the instructions in the manifest. This is what makes Chrome extension development so accessible: if you can build a web page, you can build an extension.
Manifest V3 vs. Manifest V2
Every Chrome extension has a manifest version. Manifest V3 (MV3) is the current and only supported version for new extensions. Google began enforcing MV3 in 2024, and MV2 extensions are no longer accepted on the Chrome Web Store. If you find a tutorial using "manifest_version": 2, close it - it's outdated.
The biggest changes MV3 introduced:
- Service workers replace persistent background pages. Your background code now runs in an event-driven service worker that terminates when idle.
- declarativeNetRequest replaces blocking webRequest. Network request modification is now rule-based, not code-based.
- Stricter Content Security Policy. No more remotely hosted code or
eval(). All code must be bundled in your extension package. - chrome.action replaces
chrome.browserActionandchrome.pageAction. One unified API for toolbar icons.
Key Components of a Chrome Extension
Every extension is built from some combination of these components:
- manifest.json - The configuration file. Defines your extension's name, version, permissions, and which files serve which purpose. This is the only required file.
- Service Worker (background.js) - Runs in the background, listens for browser events (tab opened, page loaded, alarm fired), and coordinates your extension's logic. It has no DOM access.
- Content Scripts - JavaScript and CSS files injected into web pages. They can read and modify the DOM of the page the user is viewing. They run in an isolated world (more on this later).
- Popup (popup.html) - The small UI that appears when a user clicks your extension's toolbar icon. It's a normal HTML page with its own CSS and JS.
- Options Page (options.html) - A full-page settings UI where users configure your extension. Accessible from the Extensions management page or your popup.
- Side Panel - A panel that opens in the browser sidebar. Introduced in Chrome 114, side panels are great for tools that need persistent visibility without blocking the page.
How the Pieces Connect
Here's a simplified architecture diagram showing how the components communicate:
The critical concept here is message passing. Content scripts cannot directly call Chrome APIs (with a few exceptions like chrome.runtime.sendMessage). Instead, they send messages to the service worker, which has full API access and sends responses back. Think of the service worker as the brain and the content script as the hands.
3. Setting Up Your Development Environment
You don't need much to start creating a Chrome extension. The tooling requirements are minimal compared to mobile development or backend systems. Here's what you need:
Required Tools
- Google Chrome - The browser you're building for. Make sure it's up to date.
- A text editor - I use VS Code with the following extensions: ESLint, Prettier, and Chrome Extension Manifest JSON Schema. Any editor works, but VS Code gives you manifest autocompletion out of the box.
- A terminal - For running scripts, linting, and packaging. Built into VS Code or any system terminal.
That's it. No SDKs to download, no build tools to configure (for basic extensions), no emulators to run.
Creating the Project Folder Structure
Open your terminal and create a project directory. I recommend this structure for any new extension:
mkdir my-extension && cd my-extension
# Create the standard folder structure
mkdir -p src/icons
touch manifest.json
touch src/popup.html
touch src/popup.js
touch src/popup.css
touch src/content.js
touch src/background.js
Your folder now looks like this:
my-extension/
manifest.json
src/
popup.html
popup.js
popup.css
content.js
background.js
icons/
icon-16.png
icon-48.png
icon-128.png
The Minimum Viable manifest.json
Every Chrome extension starts with manifest.json. Here's the absolute minimum you need to load an extension in Chrome:
{
"manifest_version": 3,
"name": "My First Extension",
"version": "1.0.0",
"description": "A simple Chrome extension."
}
That's a valid extension. It doesn't do anything yet, but Chrome will load it without errors. We'll build on this foundation in the next section.
Loading an Unpacked Extension
To load your extension during development:
- Open Chrome and navigate to
chrome://extensions - Toggle Developer mode on (top right corner)
- Click "Load unpacked"
- Select your project folder (the one containing
manifest.json)
Your extension now appears in the extensions list. You'll see its name, description, and an ID. Any time you change your code, click the refresh icon on your extension's card to reload it. For content script changes, you also need to refresh the target web page.
You can also use the keyboard shortcut Ctrl+Shift+E (or Cmd+Shift+E on Mac) to quickly jump to the extensions page. I keep it open as a pinned tab during development.
4. Building Your First Extension Step by Step
Let's build something real. We're going to create a Page Word Counter - an extension that counts the words on any web page when you click the toolbar icon. This is inspired by Zovo's Word Counter extension, and it touches every core concept: manifest configuration, a popup UI, a content script, and message passing between components.
By the end of this section, you'll have a fully working extension you can demo and build upon.
Step 1: The Complete manifest.json
{
"manifest_version": 3,
"name": "Page Word Counter",
"version": "1.0.0",
"description": "Count words on any web page with one click.",
"icons": {
"16": "src/icons/icon-16.png",
"48": "src/icons/icon-48.png",
"128": "src/icons/icon-128.png"
},
"action": {
"default_popup": "src/popup.html",
"default_icon": {
"16": "src/icons/icon-16.png",
"48": "src/icons/icon-48.png"
},
"default_title": "Count words on this page"
},
"permissions": [
"activeTab"
],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/content.js"],
"run_at": "document_idle"
}
],
"background": {
"service_worker": "src/background.js"
}
}
Let me explain every field:
manifest_version: 3- Required. Tells Chrome this is an MV3 extension.name- The display name. Keep it under 45 characters for the Chrome Web Store.version- Semantic versioning. Chrome requires this to increment with each update you publish.description- A short summary shown in the Web Store and extensions page. Max 132 characters.icons- Your extension icon in three sizes. 16px for the toolbar, 48px for the extensions page, 128px for the Web Store listing. Use PNG with transparency.action- Defines the toolbar button behavior.default_popupis the HTML file that appears when the user clicks the icon.permissions: ["activeTab"]- Grants access to the currently active tab when the user clicks the extension. This is the most privacy-friendly permission because it requires explicit user interaction.content_scripts- Tells Chrome to injectcontent.jsinto every page.run_at: "document_idle"means the script runs after the page has finished loading.background- Registersbackground.jsas the service worker.
Step 2: The Popup UI (popup.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="popup.css">
<title>Word Counter</title>
</head>
<body>
<div class="container">
<h1>Word Counter</h1>
<div id="result" class="result">
<span class="loading">Counting...</span>
</div>
<div class="stats" id="stats" style="display: none;">
<div class="stat">
<span class="stat-value" id="wordCount">0</span>
<span class="stat-label">Words</span>
</div>
<div class="stat">
<span class="stat-value" id="charCount">0</span>
<span class="stat-label">Characters</span>
</div>
<div class="stat">
<span class="stat-value" id="readTime">0</span>
<span class="stat-label">Min Read</span>
</div>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>
Step 3: Popup Styles (popup.css)
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', -apple-system, sans-serif;
width: 280px;
background: #ffffff;
}
.container {
padding: 20px;
}
h1 {
font-size: 16px;
font-weight: 600;
color: #1d1d1f;
margin-bottom: 16px;
}
.result {
text-align: center;
padding: 12px 0;
}
.loading {
color: #86868b;
font-size: 14px;
}
.stats {
display: flex;
gap: 16px;
justify-content: center;
}
.stat {
text-align: center;
}
.stat-value {
display: block;
font-size: 28px;
font-weight: 700;
color: #6C5CE7;
}
.stat-label {
display: block;
font-size: 11px;
color: #86868b;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
Step 4: Popup Logic (popup.js)
// popup.js - Sends a message to the content script and displays results
document.addEventListener('DOMContentLoaded', async () => {
const resultDiv = document.getElementById('result');
const statsDiv = document.getElementById('stats');
const wordCountEl = document.getElementById('wordCount');
const charCountEl = document.getElementById('charCount');
const readTimeEl = document.getElementById('readTime');
try {
// Get the currently active tab
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true
});
// Send a message to the content script running in that tab
const response = await chrome.tabs.sendMessage(tab.id, {
action: 'countWords'
});
if (response && response.success) {
// Hide loading, show stats
resultDiv.style.display = 'none';
statsDiv.style.display = 'flex';
// Update the UI with the word count data
wordCountEl.textContent = response.wordCount.toLocaleString();
charCountEl.textContent = response.charCount.toLocaleString();
readTimeEl.textContent = response.readTime;
} else {
resultDiv.innerHTML = '<span style="color: #e74c3c;">Could not count words on this page.</span>';
}
} catch (error) {
resultDiv.innerHTML = '<span style="color: #e74c3c;">Cannot access this page. Try a regular website.</span>';
}
});
Step 5: Content Script (content.js)
// content.js - Runs inside the web page, counts words in the DOM
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'countWords') {
try {
// Get all visible text from the page body
const bodyText = document.body.innerText || '';
// Count words by splitting on whitespace and filtering empty strings
const words = bodyText.trim().split(/\s+/).filter(word => word.length > 0);
const wordCount = words.length;
// Count characters (excluding extra whitespace)
const charCount = bodyText.replace(/\s+/g, ' ').trim().length;
// Estimate reading time (average 238 words per minute)
const readTime = Math.max(1, Math.ceil(wordCount / 238));
sendResponse({
success: true,
wordCount: wordCount,
charCount: charCount,
readTime: readTime
});
} catch (error) {
sendResponse({
success: false,
error: error.message
});
}
}
// Return true to indicate we will send a response asynchronously
return true;
});
Step 6: Service Worker (background.js)
// background.js - Service worker for the Page Word Counter
// For this simple extension, the service worker handles installation events.
// As your extension grows, this is where you'd add context menus,
// alarms, and cross-component coordination.
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
console.log('Page Word Counter installed successfully.');
} else if (details.reason === 'update') {
console.log('Page Word Counter updated to version',
chrome.runtime.getManifest().version);
}
});
Testing Your Extension
- Navigate to
chrome://extensionsand enable Developer mode. - Click "Load unpacked" and select your project folder.
- If you see any errors, they'll appear in red on the extension card. Click "Errors" to see details.
- Pin the extension to your toolbar.
- Navigate to any web page (like a Wikipedia article or a news site).
- Click your extension icon. You should see the word count, character count, and estimated reading time.
chrome://extensions or chrome://settings. Content scripts can't run on Chrome's internal pages. Navigate to a regular website and try again.
Congratulations. You've just built a working Chrome extension that solves a real problem. Every concept we used here - the manifest, popup UI, content script injection, and message passing - forms the foundation for much more complex extensions. The Zovo Word Counter that I ship to real users is built on this exact same architecture, just with more features layered on top.
5. Chrome Extension APIs Deep Dive
Chrome provides a rich set of APIs that give extensions capabilities far beyond what regular web pages can do. Here are the APIs you'll use most often, with practical examples from real extensions I've built.
chrome.storage - Persistent Data
This is the API you'll use in virtually every extension. It provides two storage areas:
chrome.storage.local- Stores data locally on the device. Up to 10 MB by default (can be increased with the"unlimitedStorage"permission). Fast and reliable.chrome.storage.sync- Syncs data across the user's Chrome instances via their Google account. Limited to 100 KB total, 8 KB per item. Great for settings and preferences.
// Save data
await chrome.storage.local.set({
wordCountHistory: [
{ url: 'https://example.com', count: 1542, date: Date.now() }
],
settings: { darkMode: true, avgWpm: 238 }
});
// Read data
const { wordCountHistory, settings } = await chrome.storage.local.get([
'wordCountHistory',
'settings'
]);
// Listen for changes (works across all extension components)
chrome.storage.onChanged.addListener((changes, areaName) => {
if (changes.settings) {
console.log('Settings changed:', changes.settings.newValue);
}
});
chrome.tabs - Tab Management
Query, create, update, and manage browser tabs. Essential for extensions like tab managers, which make up several of the Zovo tools.
// Get the active tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
// Open a new tab
await chrome.tabs.create({ url: 'https://example.com' });
// Send a message to a specific tab's content script
const response = await chrome.tabs.sendMessage(tab.id, {
action: 'getData'
});
// Listen for tab events in the service worker
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete') {
console.log('Tab finished loading:', tab.url);
}
});
chrome.runtime - Extension Lifecycle and Messaging
The glue that holds your extension together. Handles messaging between components, installation events, and extension metadata.
// Listen for messages from any part of the extension
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'ANALYZE_PAGE') {
// Process the request
const result = processAnalysis(message.data);
sendResponse({ success: true, result });
}
return true; // Keep the message channel open for async responses
});
// Send a message from content script to service worker
const response = await chrome.runtime.sendMessage({
type: 'ANALYZE_PAGE',
data: { url: window.location.href }
});
// Get extension info
const manifest = chrome.runtime.getManifest();
console.log('Version:', manifest.version);
chrome.action - Toolbar Icon
Controls your extension's icon in the toolbar. In MV3, this replaces both chrome.browserAction and chrome.pageAction.
// Set a badge (small text overlay on the icon)
await chrome.action.setBadgeText({ text: '42' });
await chrome.action.setBadgeBackgroundColor({ color: '#6C5CE7' });
// Change the icon dynamically
await chrome.action.setIcon({
path: { 16: 'icons/active-16.png', 48: 'icons/active-48.png' }
});
// Disable the extension icon for specific tabs
await chrome.action.disable(tabId);
chrome.alarms - Scheduled Tasks
In MV3, you can't use setInterval or setTimeout for long-running timers because the service worker terminates when idle. Use chrome.alarms instead.
// Create an alarm that fires every 30 minutes
chrome.alarms.create('syncData', { periodInMinutes: 30 });
// Create a one-time alarm that fires in 5 minutes
chrome.alarms.create('reminder', { delayInMinutes: 5 });
// Listen for alarm events
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'syncData') {
performSync();
}
});
chrome.contextMenus - Right-Click Menus
Add custom items to Chrome's right-click context menu. Create them in the service worker's onInstalled handler.
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'countSelection',
title: 'Count words in selection',
contexts: ['selection']
});
});
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'countSelection') {
const wordCount = info.selectionText.trim().split(/\s+/).length;
// Show the result via badge or notification
chrome.action.setBadgeText({ text: String(wordCount), tabId: tab.id });
}
});
chrome.notifications - Desktop Notifications
chrome.notifications.create('reminder', {
type: 'basic',
iconUrl: 'src/icons/icon-128.png',
title: 'Word Counter',
message: 'You have read approximately 15,000 words today!'
});
API Reference Table
| API | Use Case | Permission Required |
|---|---|---|
chrome.storage | Save settings, cache data | storage |
chrome.tabs | Query/manage tabs | tabs (for URL access) |
chrome.runtime | Messaging, lifecycle events | None (built-in) |
chrome.action | Toolbar icon, badge, popup | None (built-in) |
chrome.alarms | Scheduled/periodic tasks | alarms |
chrome.contextMenus | Right-click menu items | contextMenus |
chrome.notifications | Desktop notifications | notifications |
chrome.scripting | Programmatic script injection | scripting |
chrome.sidePanel | Sidebar panel UI | sidePanel |
chrome.declarativeNetRequest | Block/modify network requests | declarativeNetRequest |
6. Content Scripts and DOM Manipulation
Content scripts are the most powerful and most misunderstood part of Chrome extension development. They're the code that runs inside web pages, giving you access to the page's DOM. This is how extensions modify website behavior, inject UI elements, extract data, and change appearances.
What Content Scripts Can (and Can't) Access
Content scripts have full access to the page DOM. They can read elements, modify styles, add new nodes, and listen for DOM events. However, they run in an isolated world - they share the DOM with the host page but have their own JavaScript execution environment. This means:
- Your content script can call
document.querySelector()and modify elements. - Your content script cannot access JavaScript variables defined by the page (and vice versa).
- Your content script cannot call functions defined by the page.
- The page's JavaScript cannot detect or interfere with your content script.
This isolation is a security feature. It prevents malicious websites from tampering with your extension's code and prevents your extension from accidentally conflicting with the page's JavaScript.
CSS Injection
You can inject CSS in two ways: declaratively in the manifest or programmatically via chrome.scripting.
Declarative (manifest.json):
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"css": ["src/injected-styles.css"],
"js": ["src/content.js"],
"run_at": "document_start"
}
]
Programmatic (from the service worker):
await chrome.scripting.insertCSS({
target: { tabId: tab.id },
css: '.my-widget { position: fixed; bottom: 20px; right: 20px; z-index: 99999; }'
});
Message Passing Between Content Script and Service Worker
This is the communication backbone of any non-trivial extension. The pattern is straightforward:
// === In the content script (content.js) ===
// Send a message to the service worker and await a response
async function requestAnalysis(pageData) {
const response = await chrome.runtime.sendMessage({
type: 'ANALYZE',
payload: pageData
});
return response;
}
// Listen for messages from the service worker
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'HIGHLIGHT_WORDS') {
highlightWordsOnPage(message.words);
sendResponse({ success: true });
}
return true;
});
// === In the service worker (background.js) ===
// Listen for messages from content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'ANALYZE') {
// sender.tab tells you which tab sent this message
const analysis = performAnalysis(message.payload);
sendResponse({ result: analysis });
}
return true;
});
// Send a message to a specific tab's content script
async function notifyContentScript(tabId, words) {
await chrome.tabs.sendMessage(tabId, {
type: 'HIGHLIGHT_WORDS',
words: words
});
}
Real Example: Injecting a Floating Widget
One common pattern is injecting a floating UI widget into web pages. Here's how to build a small floating button that appears on every page and triggers an action when clicked:
// content.js - Inject a floating action button
function createFloatingWidget() {
// Create the widget container
const widget = document.createElement('div');
widget.id = 'my-extension-widget';
widget.innerHTML = `
<button id="my-extension-btn" title="Count words">
W
</button>
`;
// Style it (using a unique ID to avoid CSS conflicts)
const style = document.createElement('style');
style.textContent = `
#my-extension-widget {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 2147483647;
font-family: -apple-system, sans-serif;
}
#my-extension-btn {
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: #6C5CE7;
color: white;
font-size: 18px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 12px rgba(108, 92, 231, 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
#my-extension-btn:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(108, 92, 231, 0.5);
}
`;
document.head.appendChild(style);
document.body.appendChild(widget);
// Add click handler
document.getElementById('my-extension-btn').addEventListener('click', () => {
const wordCount = document.body.innerText.trim().split(/\s+/).length;
alert('This page has approximately ' + wordCount.toLocaleString() + ' words.');
});
}
// Run when the DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createFloatingWidget);
} else {
createFloatingWidget();
}
7. Service Workers (Background Scripts) in MV3
If content scripts are the hands of your extension, the service worker is the brain. It's the central coordinator that runs in the background, listens for browser events, manages state, and orchestrates communication between components. Understanding service workers is critical for building reliable MV3 extensions.
Why Service Workers Instead of Background Pages?
In MV2, extensions could have persistent background pages - always-on HTML documents that consumed memory even when idle. Google replaced these with service workers in MV3 to improve browser performance. A service worker is a JavaScript file (no HTML, no DOM) that Chrome loads when needed and terminates when idle.
This is the single biggest architectural change in MV3, and it catches many developers off guard.
Service Worker Lifecycle
The lifecycle has three key phases:
- Install - Fires when the extension is first installed or updated. Use this to set up one-time resources like context menus or default storage values.
- Activate - Fires after installation. The service worker is now running and listening for events.
- Idle / Terminate - After approximately 30 seconds of inactivity (no pending events, no open message ports), Chrome terminates the service worker. It restarts automatically when a registered event fires.
// background.js - Service worker lifecycle example
// This runs every time the service worker starts (including restarts after idle)
console.log('Service worker started');
// This runs only on install or update
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
// First install: set default settings
chrome.storage.local.set({
settings: { enabled: true, theme: 'light' },
stats: { totalWordsAnalyzed: 0 }
});
// Create context menus
chrome.contextMenus.create({
id: 'analyzeSelection',
title: 'Analyze selected text',
contexts: ['selection']
});
}
});
// Event listeners must be registered at the top level
// (not inside callbacks or conditionals)
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete' && tab.url) {
handlePageLoad(tabId, tab.url);
}
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
handleMessage(message, sender).then(sendResponse);
return true; // Keep message channel open for async response
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'dailySync') {
performDailySync();
}
});
// Helper functions
async function handlePageLoad(tabId, url) {
const { settings } = await chrome.storage.local.get('settings');
if (settings.enabled) {
await chrome.action.setBadgeText({ text: 'ON', tabId });
}
}
async function handleMessage(message, sender) {
switch (message.type) {
case 'GET_SETTINGS':
const { settings } = await chrome.storage.local.get('settings');
return { success: true, settings };
case 'UPDATE_STATS':
const { stats } = await chrome.storage.local.get('stats');
stats.totalWordsAnalyzed += message.count;
await chrome.storage.local.set({ stats });
return { success: true };
default:
return { success: false, error: 'Unknown message type' };
}
}
The Critical Rule: Top-Level Event Registration
This is the most common mistake I see developers make with MV3 service workers. All event listeners must be registered at the top level of your service worker file, synchronously, every time it starts.
// CORRECT: Event listener registered at top level
chrome.runtime.onMessage.addListener(handleMessage);
// WRONG: Event listener registered conditionally
if (someCondition) {
chrome.runtime.onMessage.addListener(handleMessage); // May be missed on restart
}
// WRONG: Event listener registered asynchronously
chrome.storage.local.get('settings').then((data) => {
chrome.runtime.onMessage.addListener(handleMessage); // Too late, may be missed
});
When Chrome restarts your service worker to deliver an event, it re-executes the entire file. If a listener isn't registered by the time the event arrives, the event is dropped silently. Always register first, then check conditions inside the handler.
Persistence Strategies
Since service workers terminate when idle, you cannot rely on in-memory state. Here are the patterns I use across the Zovo extension suite:
- Use
chrome.storage.localfor all state. Read it when you need it, write it when it changes. Treat the service worker as stateless. - Use
chrome.storage.sessionfor temporary state that should survive service worker restarts but not browser restarts. This API was added specifically for MV3. - Use
chrome.alarmsfor periodic tasks instead ofsetInterval. Alarms persist across service worker restarts and even browser restarts. - Keep open message ports if you need to prevent termination temporarily. An active
chrome.runtime.connect()port keeps the service worker alive as long as the port is open.
chrome.alarms with a 1-minute interval combined with chrome.storage.session for hot data. This pattern keeps the extension responsive without fighting the service worker lifecycle.
8. Permissions: Request the Minimum
Permissions are the trust contract between your extension and its users. Every permission you request is displayed to the user during installation as a warning. Request too many and users will not install your extension. Request unnecessary ones and Google may reject your Web Store submission.
After publishing 16 extensions, I've learned this lesson the hard way: less is more. The Zovo Tab Suspender uses only tabs and storage - no host permissions at all. It can manage and suspend tabs without ever reading page content. That minimal permission set is a competitive advantage because users trust it instantly.
activeTab: Your Best Friend
The activeTab permission is the most important permission in the MV3 model. It grants temporary access to the currently active tab only when the user explicitly interacts with your extension (clicking the icon, using a keyboard shortcut, or selecting a context menu item). Access is revoked when the user navigates away or switches tabs.
This replaces broad host permissions for most use cases. Instead of requesting access to all websites all the time, activeTab says "I only need access when the user asks for it." Google loves this, users trust it, and it covers about 80% of extension use cases.
Optional Permissions
For features that not every user needs, request permissions at runtime using chrome.permissions.request(). This keeps your installation warnings minimal and only asks for elevated access when the user opts into a feature.
// In your popup or options page
async function enableAdvancedFeature() {
const granted = await chrome.permissions.request({
permissions: ['bookmarks'],
origins: ['https://api.example.com/*']
});
if (granted) {
// Permission granted - enable the feature
await chrome.storage.local.set({ advancedMode: true });
showSuccess('Advanced mode enabled!');
} else {
// Permission denied - gracefully handle it
showInfo('Advanced mode requires bookmark access to work.');
}
}
Declare optional permissions in your manifest:
{
"permissions": ["storage", "activeTab"],
"optional_permissions": ["bookmarks", "history"],
"optional_host_permissions": ["https://api.example.com/*"]
}
Common Permissions Reference
| Permission | What It Unlocks | Trust Impact |
|---|---|---|
activeTab | Temporary access to current tab on user action | Low (very trustworthy) |
storage | chrome.storage API for persistent data | None (no warning shown) |
alarms | Scheduled background tasks | None (no warning shown) |
tabs | Read tab URL, title, and status | Low |
contextMenus | Right-click menu items | None (no warning shown) |
notifications | Desktop notifications | Low |
scripting | Programmatic script/CSS injection | Medium |
bookmarks | Read and modify bookmarks | Medium |
history | Read browsing history | High |
<all_urls> | Access to all websites | Very high (triggers extra review) |
downloads | Manage downloads | Medium |
declarativeNetRequest | Block/redirect network requests | Medium-High |
activeTab or chrome.scripting.executeScript instead?" Nine times out of ten, the answer is yes. Your installation rate will thank you.
9. Debugging and Testing
Debugging Chrome extensions can be confusing because your code runs in multiple separate contexts - the popup, the service worker, content scripts, and the options page each have their own developer tools window. Here's how to debug each one efficiently.
Inspecting the Popup
Right-click your extension's toolbar icon and select "Inspect popup". This opens DevTools for the popup's HTML page. You can view the DOM, check network requests, and see console output. Note that the popup closes when it loses focus, and closing it will close the DevTools too. A workaround is to undock DevTools to a separate window first.
Inspecting the Service Worker
Go to chrome://extensions, find your extension, and click the "service worker" link under "Inspect views." This opens DevTools attached to your service worker. You can see console logs, set breakpoints, and debug event handlers. If the service worker has terminated, clicking this link will restart it.
Inspecting Content Scripts
Open the regular DevTools on any page (F12 or right-click and select "Inspect"). Your content script's console output appears in the same console as the page's output. To set breakpoints in your content script, go to the Sources panel, expand the "Content scripts" section in the file navigator, and find your extension's files there.
Common Errors and Fixes
- "Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist." - This means you're sending a message to a tab that doesn't have your content script loaded. Common causes: the tab is a Chrome internal page, the content script hasn't loaded yet, or the page was loaded before the extension was installed. Fix: wrap
sendMessagein a try/catch and handle the error gracefully. - "Service worker registration failed." - Syntax error in your
background.jsfile. Check the error details for the line number. Even a missing comma in the service worker will prevent it from loading. - Content script not running on a page. - Check your
matchespattern in the manifest. After changing the manifest, you must reload the extension AND refresh the target page. - Popup shows a blank white page. - Usually a wrong file path in
default_popup. Paths are relative to the manifest file. Use the DevTools network panel to see which files failed to load. - "Cannot read properties of undefined" - Often happens when
chrome.storage.local.get()returns an empty object because the key hasn't been set yet. Always provide default values:const { settings = {} } = await chrome.storage.local.get('settings');
Debugging Strategies
// I use a simple debug utility in every extension
const DEBUG = true;
function log(...args) {
if (DEBUG) {
console.log('[MyExtension]'...args);
}
}
function logError(...args) {
console.error('[MyExtension ERROR]'...args);
}
// Tag messages by component for easy filtering
// In content.js:
log('[Content]', 'Page loaded, word count:', wordCount);
// In background.js:
log('[SW]', 'Message received:', message.type);
// In popup.js:
log('[Popup]', 'Displaying results:', data);
[MyExtension]). This cuts through the noise on pages that have heavy console output of their own. I prefix all Zovo extension logs with the extension name so I can filter instantly.
10. Publishing to Chrome Web Store
You've built and tested your extension. Now it's time to put it in front of users. Publishing to the Chrome Web Store is straightforward, but there are optimization techniques I've learned from publishing 16 extensions that can dramatically affect your visibility and install rate.
Developer Registration
Before you can publish anything, you need a Chrome Web Store developer account:
- Go to the Chrome Web Store Developer Dashboard.
- Sign in with your Google account.
- Pay the one-time $5 registration fee.
- Verify your developer identity (Google may require ID verification for new accounts).
That's it. No recurring fees. You can publish unlimited extensions under one account.
Preparing Your Store Listing
Your listing has several components, each of which affects whether users click "Add to Chrome":
Extension name: You have 75 characters, but aim for under 45. Lead with the primary function. "Page Word Counter - Instant Text Analysis" is better than "My Cool Extension v3 - Word Counter Tool for Chrome Browser." Don't keyword stuff - Google penalizes it.
Short description: 132 characters. This appears in search results. Lead with the problem you solve: "Count words, characters, and reading time on any web page. One click, instant results."
Detailed description: Up to 16,000 characters. Structure it like a landing page:
- Lead with the user's pain point or question.
- Describe what the extension does in 2-3 sentences.
- List features as bullet points.
- Mention what the extension does NOT do (builds trust).
- Include a changelog for updates.
Title Optimization
From my experience publishing 16 extensions: put your primary keyword in the first 36 characters of your title. This is what appears in Chrome Web Store search results before truncation. "Word Counter" at the start beats "Ultimate Swiss Army Knife of Word Counting" every time.
Icons and Screenshots
- Extension icon: 128x128 PNG. Must be clear and recognizable at 16x16. Avoid text in the icon - it's unreadable at small sizes. Use a simple, bold symbol with your brand color.
- Screenshots: Up to 5 images at 1280x800 or 640x400 pixels. Show your extension in action. The first screenshot is the most important - it appears in search results. Add brief captions or annotations that explain what the user is seeing.
- Promotional images: Optional but recommended. Small tile (440x280), large tile (920x680), and marquee (1400x560). These are used for featured placement.
Packaging Your Extension
Create a zip file of your extension directory (the folder containing manifest.json). Exclude any development files:
# Create a clean zip for the Chrome Web Store
zip -r my-extension.zip . \
-x "*.git*" \
-x "node_modules/*" \
-x "*.map" \
-x ".DS_Store" \
-x "tests/*" \
-x ".eslintrc*" \
-x "package*.json" \
-x "*.md"
The Review Process
After uploading, your extension enters Google's review queue. Here's what to expect:
- First submission: Typically 1-3 business days. Can take up to a week if your extension requests sensitive permissions.
- Updates: Usually reviewed within 1-2 business days. Minor updates (description changes, icon updates) are faster.
- Rejection reasons: Common ones include requesting unnecessary permissions, missing privacy policy (required if you handle user data), and misleading description. Google emails you specific reasons and you can fix and resubmit.
Chrome Web Store Optimization Tips
After managing 16 live extensions, here are the patterns I've observed that actually move the needle:
- Respond to reviews. Extensions with developer responses have higher trust signals. Thank positive reviewers and address negative ones constructively.
- Publish updates regularly. Even small improvements signal to Google that the extension is maintained. I aim for at least one update every 2-3 months per extension.
- Keep your permission list short. Fewer permissions means a less scary installation dialog, which means higher conversion from listing page to installed user.
- Use the categories wisely. Pick the most specific category that fits. "Productivity" is overcrowded. If "Developer Tools" fits, use it.
- Localize if possible. Even just translating your store listing (not the extension itself) to 3-4 major languages can expand your reach.
11. Beyond the Basics: Advanced Patterns
Once you've mastered the fundamentals, these advanced patterns will help you build production-grade extensions. These are techniques I use across the Zovo suite to handle real-world complexity.
Cross-Extension Messaging
If you build a suite of extensions (like Zovo's 16 tools), they can communicate with each other using chrome.runtime.sendMessage with an explicit extension ID:
// Send a message to a different extension
chrome.runtime.sendMessage(
'abcdefghijklmnopabcdefghijklmnop', // Target extension ID
{ type: 'REQUEST_DATA', key: 'userPrefs' },
(response) => {
console.log('Response from other extension:', response);
}
);
// In the receiving extension's service worker:
chrome.runtime.onMessageExternal.addListener(
(message, sender, sendResponse) => {
// Verify the sender is one of your own extensions
const trustedExtensions = ['abcdef...', 'ghijkl...'];
if (trustedExtensions.includes(sender.id)) {
// Handle the request
sendResponse({ data: 'Here you go' });
}
}
);
Native Messaging
Chrome extensions can communicate with native applications installed on the user's computer. This is how extensions like password managers bridge the browser-to-desktop gap:
// Connect to a native application
const port = chrome.runtime.connectNative('com.mycompany.myapp');
port.onMessage.addListener((message) => {
console.log('Received from native app:', message);
});
port.postMessage({ action: 'getSystemInfo' });
Native messaging requires a native messaging host manifest installed on the user's machine, which makes it suitable for enterprise or developer-focused tools rather than general consumer extensions.
DevTools Panels
You can add custom panels to Chrome DevTools - ideal for developer-focused extensions:
// devtools.js - registered in manifest as "devtools_page"
chrome.devtools.panels.create(
'My Panel', // Panel title
'icons/icon-16.png', // Icon
'panel.html', // HTML page for the panel
(panel) => {
panel.onShown.addListener((window) => {
// Panel is now visible
});
}
);
Side Panels
Introduced in Chrome 114 (2023) and now fully mature in 2026, side panels are one of the most useful additions to the extension platform. They provide a persistent sidebar UI that doesn't block the page content:
// manifest.json
{
"permissions": ["sidePanel"],
"side_panel": {
"default_path": "src/sidepanel.html"
}
}
// background.js - Open the side panel programmatically
chrome.action.onClicked.addListener(async (tab) => {
await chrome.sidePanel.open({ windowId: tab.windowId });
});
// You can also set the panel per-tab
chrome.sidePanel.setOptions({
tabId: tab.id,
path: 'src/sidepanel-custom.html',
enabled: true
});
I've been migrating several Zovo extensions from popup-based to side panel-based UIs. The user experience is dramatically better for tools that need ongoing visibility while the user interacts with the page.
Internationalization (i18n)
Chrome has built-in support for translating your extension. Create a _locales directory with subdirectories for each language:
_locales/
en/
messages.json
es/
messages.json
ja/
messages.json
// _locales/en/messages.json
{
"extensionName": {
"message": "Page Word Counter",
"description": "The name of the extension"
},
"extensionDescription": {
"message": "Count words on any web page with one click.",
"description": "The description of the extension"
},
"wordCount": {
"message": "$COUNT$ words",
"placeholders": {
"count": {
"content": "$1"
}
}
}
}
// In manifest.json, reference translations:
{
"name": "__MSG_extensionName__",
"description": "__MSG_extensionDescription__",
"default_locale": "en"
}
// In JavaScript:
const label = chrome.i18n.getMessage('wordCount', [String(count)]);
TypeScript for Extensions
For larger extensions, TypeScript is a . It catches errors at compile time and provides excellent autocompletion for Chrome APIs. Here's a minimal setup:
# Install TypeScript and Chrome type definitions
npm init -y
npm install -D typescript @anthropic-ai/chrome-types
# Or use the community types:
npm install -D typescript @anthropic-ai/chrome-types
# For most projects, the community types package works well:
npm install -D typescript @anthropic-ai/chrome-types
A more practical setup for Chrome extension TypeScript development:
# Initialize your project
npm init -y
npm install -D typescript
# Create tsconfig.json
npx tsc --init
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"moduleResolution": "node"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
// Now you get type safety for Chrome APIs:
// src/background.ts
chrome.runtime.onInstalled.addListener((details: chrome.runtime.InstalledDetails) => {
if (details.reason === 'install') {
initializeDefaults();
}
});
interface WordCountMessage {
type: 'COUNT_WORDS';
tabId: number;
}
interface WordCountResponse {
success: boolean;
count?: number;
error?: string;
}
chrome.runtime.onMessage.addListener(
(message: WordCountMessage, sender, sendResponse: (r: WordCountResponse) => void) => {
if (message.type === 'COUNT_WORDS') {
countWordsInTab(message.tabId).then(sendResponse);
}
return true;
}
);
For the Zovo extensions, I use TypeScript for every extension larger than a single file. The upfront cost of setting up the build step pays for itself the first time TypeScript catches a misspelled API method name.
12. Frequently Asked Questions
How long does it take to create a Chrome extension?
A simple Chrome extension can be created in 30 minutes to 2 hours. More complex extensions with background scripts, content scripts, and API integrations typically take 1-4 weeks. I've built extensions ranging from single-afternoon projects to multi-month tools with thousands of users. The word counter we built in this guide took about 20 minutes, and it's a genuinely useful tool.
Do I need to pay to publish a Chrome extension?
There is a one-time $5 developer registration fee to publish on the Chrome Web Store. After that, publishing and updates are free. There are no ongoing fees unless you choose to offer paid extensions through the Chrome Web Store payment system. It's one of the most affordable distribution channels for software.
What programming language do Chrome extensions use?
Chrome extensions use standard web technologies: HTML, CSS, and JavaScript. You can also use TypeScript (which compiles to JavaScript), and many developers use frameworks like React or Svelte for complex popup or options page UIs. The manifest.json file uses JSON. No special programming language or SDK is required - if you know web development, you know Chrome extension development.
What is Manifest V3 and why does it matter?
Manifest V3 (MV3) is Chrome's current extension platform, replacing Manifest V2. It introduces service workers instead of persistent background pages, declarativeNetRequest instead of blocking webRequest, and a stricter Content Security Policy. All new extensions must use MV3 - the Chrome Web Store no longer accepts MV2 submissions. If you're learning to build extensions today, MV3 is the only version you need to learn.
Can I make money from a Chrome extension?
Yes. Common monetization strategies include freemium models (free base features + paid pro features), one-time payments through the Chrome Web Store, subscription plans via Stripe or a similar payment processor, and affiliate partnerships. Extensions with 10,000+ active users can generate meaningful recurring revenue. My advice: focus on solving a real problem first. Monetization follows utility. An extension that 1,000 people love is more monetizable than one that 100,000 people feel indifferent about.
Can Chrome extensions access data on every website?
Only if you request host permissions for those sites in your manifest. Best practice is to use the activeTab permission, which grants access only to the currently active tab when the user explicitly clicks your extension. Broad host permissions like <all_urls> trigger extra security review from Google and display alarming warnings to users during installation. Only request broad access if your extension genuinely needs it (like an ad blocker or accessibility tool).
What happens when my service worker goes idle?
In Manifest V3, service workers terminate after approximately 30 seconds of inactivity. When an event fires (like a message, alarm, or tab update), Chrome restarts the service worker automatically. You need to persist all important state using chrome.storage and register event listeners at the top level of your service worker file. Never rely on in-memory global variables for important data.
Is it possible to migrate an existing MV2 extension to MV3?
Yes, and it's required if you want to keep your extension on the Chrome Web Store. The main changes are: converting your background page to a service worker, replacing chrome.browserAction with chrome.action, updating your Content Security Policy to disallow remote code, and replacing blocking webRequest calls with declarativeNetRequest rules. Google provides an official migration guide. I've migrated multiple extensions and the process typically takes 1-3 days per extension.
Can I use React, Vue, or Svelte to build a Chrome extension?
. Your popup, options page, and side panel are just HTML pages - you can use any frontend framework to build them. The most common setup is a Vite or webpack build that compiles your framework code into static HTML/JS/CSS files that you include in the extension package. For the Zovo extensions, I use vanilla JavaScript for simple tools and structured frameworks for complex UIs. Choose based on complexity, not convention.
How do I update my Chrome extension after publishing?
Increment the version field in your manifest.json, create a new zip file, and upload it through the Chrome Web Store Developer Dashboard. Click "Update" on your extension's listing, upload the new zip, and submit for review. Updates typically take 1-2 business days to review. Chrome automatically updates installed extensions for users (usually within a few hours of approval). You don't need to notify users manually.
What are the size limits for a Chrome extension?
The Chrome Web Store accepts extension packages (zip files) up to 500 MB, but you should aim to keep your extension as small as possible. Most extensions are well under 1 MB. Smaller extensions install faster and consume fewer resources. Avoid bundling large assets like images or fonts unless necessary. Use Chrome's built-in APIs and system fonts where possible.
13. Conclusion
You now have everything you need to build a Chrome extension from scratch, test it, debug it, and publish it to the Chrome Web Store. Let me summarize the key steps of creating a Chrome extension from start to finish:
- Plan your architecture. Decide which components you need: popup, content scripts, service worker, side panel. Draw the communication flow between them.
- Start with a minimal manifest.json. Get the basic structure loading in Chrome before adding features. Validate early and often.
- Build incrementally. Get one feature working end-to-end before starting the next. The word counter we built in this guide follows this principle - it does one thing well.
- Use message passing for cross-component communication. Content scripts talk to the service worker via
chrome.runtime.sendMessage. The service worker coordinates everything. - Request minimal permissions. Use
activeTabwherever possible. Request optional permissions at runtime for advanced features. - Debug systematically. Know which DevTools window corresponds to which component. Use tagged console logs for filtering.
- improve your Web Store listing. Put keywords in the first 36 characters of your title. Lead your description with the user's problem. Use clear, annotated screenshots.
- Iterate based on user feedback. Respond to reviews. Ship updates regularly. A maintained extension outranks an abandoned one.
Creating a Chrome extension is one of the highest-use things a web developer can do. You're building software that plugs directly into the tool people spend most of their working day inside. The distribution is built in, the development tools are free, and the skills are the same ones you already have.
I've been building Chrome extensions professionally for over a decade, and I still find the platform genuinely exciting. Every few months, Chrome ships new APIs (side panels, reading list access, user scripts) that open up possibilities that didn't exist before. The Zovo suite started as a single tab manager and grew into 16 extensions because each one solved a real problem that users told me about.
If you want to see how production Chrome extensions are structured, explore the Zovo extension suite. There are 16 Chrome extensions covering productivity, developer tools, and content analysis - all built with the same patterns and principles described in this guide. They're a living example of what's possible when you take Chrome extension development seriously.
Now go build something. Start with the word counter from section 4, modify it to do something you actually need, and ship it. The Chrome Web Store is $5 and 30 minutes away.