How to Create a Chrome Extension: Complete Developer Guide 2026

By Michael Lip · March 18, 2026 · 25 min read
16 Chrome extensions shipped · $400K+ on Upwork · 100% success rate | Last verified: March 2026

Table of Contents

  1. Introduction
  2. Understanding Chrome Extension Architecture
  3. Setting Up Your Development Environment
  4. Building Your First Extension Step by Step
  5. Chrome Extension APIs Deep Dive
  6. Content Scripts and DOM Manipulation
  7. Service Workers (Background Scripts) in MV3
  8. Permissions: Request the Minimum
  9. Debugging and Testing
  10. Publishing to Chrome Web Store
  11. Beyond the Basics: Advanced Patterns
  12. Frequently Asked Questions
  13. 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:

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:

Key Components of a Chrome Extension

Every extension is built from some combination of these components:

How the Pieces Connect

Here's a simplified architecture diagram showing how the components communicate:

+----------------------------------------------------------+ | CHROME BROWSER | | | | +------------------+ chrome.runtime +--------+ | | | Service Worker |<-----messaging------>| Popup | | | | (background.js) | | (.html) | | | +--------+---------+ +--------+ | | | | | chrome.tabs chrome.runtime | | .sendMessage .onMessage | | | | | +--------v------------------------------------------+ | | | Web Page (any website) | | | | +--------------------------------------------+ | | | | | Content Script (content.js) | | | | | | - Can read/modify page DOM | | | | | | - Isolated JavaScript context | | | | | | - Communicates via message passing | | | | | +--------------------------------------------+ | | | +---------------------------------------------------+ | | | | +------------------+ +---------------------+ | | | Options Page | | chrome.storage | | | | (options.html) | | (shared data store) | | | +------------------+ +---------------------+ | +----------------------------------------------------------+

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.

: When I'm planning a new extension, I start by drawing this diagram and labeling which component handles which responsibility. It prevents the most common architectural mistake: putting logic in the wrong place.

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

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:

  1. Open Chrome and navigate to chrome://extensions
  2. Toggle Developer mode on (top right corner)
  3. Click "Load unpacked"
  4. 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.

: Pin your extension to the toolbar immediately. Click the puzzle piece icon in Chrome's toolbar, then click the pin icon next to your extension. This makes the popup accessible with one click during development.

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:

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

  1. Navigate to chrome://extensions and enable Developer mode.
  2. Click "Load unpacked" and select your project folder.
  3. If you see any errors, they'll appear in red on the extension card. Click "Errors" to see details.
  4. Pin the extension to your toolbar.
  5. Navigate to any web page (like a Wikipedia article or a news site).
  6. Click your extension icon. You should see the word count, character count, and estimated reading time.
: If the popup shows "Cannot access this page," you're probably on a Chrome internal page like 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:

// 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.storageSave settings, cache datastorage
chrome.tabsQuery/manage tabstabs (for URL access)
chrome.runtimeMessaging, lifecycle eventsNone (built-in)
chrome.actionToolbar icon, badge, popupNone (built-in)
chrome.alarmsScheduled/periodic tasksalarms
chrome.contextMenusRight-click menu itemscontextMenus
chrome.notificationsDesktop notificationsnotifications
chrome.scriptingProgrammatic script injectionscripting
chrome.sidePanelSidebar panel UIsidePanel
chrome.declarativeNetRequestBlock/modify network requestsdeclarativeNetRequest

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:

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();
}
Important: Always use extremely high z-index values (like 2147483647, the max 32-bit integer) and highly specific selectors for injected elements. Websites use aggressive CSS that can override your styles. Namespace your IDs and classes to avoid collisions.

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:

  1. 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.
  2. Activate - Fires after installation. The service worker is now running and listening for events.
  3. 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:

: When I'm building an extension that requires frequent background computation (like Zovo's Tab Suspender, which monitors tab memory usage), I use 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
activeTabTemporary access to current tab on user actionLow (very trustworthy)
storagechrome.storage API for persistent dataNone (no warning shown)
alarmsScheduled background tasksNone (no warning shown)
tabsRead tab URL, title, and statusLow
contextMenusRight-click menu itemsNone (no warning shown)
notificationsDesktop notificationsLow
scriptingProgrammatic script/CSS injectionMedium
bookmarksRead and modify bookmarksMedium
historyRead browsing historyHigh
<all_urls>Access to all websitesVery high (triggers extra review)
downloadsManage downloadsMedium
declarativeNetRequestBlock/redirect network requestsMedium-High
: Before you add a permission to your manifest, ask yourself: "Can I achieve this with 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

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);
: In Chrome DevTools, use the Console filter to search for your extension's tag (like [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:

  1. Go to the Chrome Web Store Developer Dashboard.
  2. Sign in with your Google account.
  3. Pay the one-time $5 registration fee.
  4. 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:

  1. Lead with the user's pain point or question.
  2. Describe what the extension does in 2-3 sentences.
  3. List features as bullet points.
  4. Mention what the extension does NOT do (builds trust).
  5. 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

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:

Chrome Web Store Optimization Tips

After managing 16 live extensions, here are the patterns I've observed that actually move the needle:

: Track your Chrome Web Store performance using the Developer Dashboard analytics. Pay attention to the "Impressions to Install" ratio. If people see your listing but don't install, your screenshots or permission warnings need work. If people install but quickly uninstall, your extension isn't delivering on its promise.

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:

  1. Plan your architecture. Decide which components you need: popup, content scripts, service worker, side panel. Draw the communication flow between them.
  2. Start with a minimal manifest.json. Get the basic structure loading in Chrome before adding features. Validate early and often.
  3. 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.
  4. Use message passing for cross-component communication. Content scripts talk to the service worker via chrome.runtime.sendMessage. The service worker coordinates everything.
  5. Request minimal permissions. Use activeTab wherever possible. Request optional permissions at runtime for advanced features.
  6. Debug systematically. Know which DevTools window corresponds to which component. Use tagged console logs for filtering.
  7. 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.
  8. 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.

Need help building a Chrome extension? I'm available for consulting and custom development. With $400K+ completed on Upwork at a 100% success rate and 16 shipped extensions, I can help you architect, build, and ship your extension. Visit zovo.one or find me on Upwork.
ML
Michael Lip
Chrome extension engineer. Built 16 extensions with 4,700+ users. Top Rated Plus on Upwork with $400K+ earned across 47 contracts. All extensions are free, open source, and collect zero data.
zovo.one GitHub