Browser extensions bridge the gap between static web content and custom desktop utility. With Google’s transition to Manifest V3 (MV3), the extension runtime shifted from persistent background pages to an event-driven architecture powered by short-lived Service Workers, stricter sandboxing, and refined permission models.
This guide walks through building a production-ready Chrome extension under Manifest V3, covering architecture, context separation, secure permissions, and the deployment pipeline to the Chrome Web Store.
Core Architecture: Manifest V3 Under the Hood
An extension is not a single script; it is a federation of isolated execution contexts that communicate via structured message passing:
- Manifest File (
manifest.json): The declarative root configuring metadata, entry points, capabilities, and security boundaries. - Background Service Worker: The non-persistent event orchestrator. Unlike Manifest V2’s persistent background pages, MV3 service workers terminate when idle (often within 30 seconds of inactivity) and spin up on registered events.
- Content Scripts: JavaScript injected directly into specified web pages. They share the host page’s DOM but live in an isolated world, meaning their execution context, prototype chain, and global variables do not collide with host-page scripts.
- Extension UI (Popups, Options, Side Panels): Traditional HTML/CSS/JS interfaces running inside the privileged extension origin (
chrome-extension://<id>/).
┌────────────────────────────────────────────────────────┐
│ Extension Process │
│ │
│ ┌────────────────────────┐ ┌──────────────────────┐ │
│ │ Background Service │ │ UI Contexts │ │
│ │ Worker (Event-driven) │ │ (Popup / Side Panel) │ │
│ └───────────▲────────────┘ └──────────▲───────────┘ │
│ │ │ │
│ │ chrome.runtime API │ │
│ └────────────┬─────────────┘ │
└───────────────────────────┼────────────────────────────┘
│ chrome.tabs.sendMessage
▼
┌────────────────────────────────────────────────────────┐
│ Host Web Page │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Content Script (Isolated JavaScript Realm) │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ Direct DOM Access │
│ ┌────────────────────────▼─────────────────────────┐ │
│ │ Target Page DOM (Shared Elements, Styles) │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
Setting Up manifest.json
The entry point defines assets, permissions, and context bindings:
{
"manifest_version": 3,
"name": "QuickLens Inspector",
"version": "1.0.0",
"description": "Inspects and annotates DOM elements with instant cloudless storage.",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_popup": "src/popup/popup.html",
"default_title": "Open Inspector"
},
"background": {
"service_worker": "src/background/index.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://*/*"],
"js": ["src/content/index.js"],
"run_at": "document_idle"
}
],
"permissions": [
"storage",
"activeTab"
],
"optional_permissions": [
"downloads"
]
}
Mastering MV3 Permissions
Permissions grant system-level capabilities and must strictly adhere to the Principle of Least Privilege.
Permission Tiers
- API Permissions (
permissions): Grants access to Chrome APIs likestorage,alarms, ortabs. - Host Permissions (
host_permissions): Controls cross-origin network fetching and content script injection capabilities across specific URL patterns. - Optional Permissions (
optional_permissions): Requested dynamically at runtime when the user triggers a specific feature, minimizing initial install-time security warnings.
activeTab vs host_permissions
- Avoid broad wildcards like
"<all_urls>"or"*://*/*"inhost_permissionsunless the core value proposition is site-wide automation. These wildcards trigger severe browser warning dialogs and require deeper Chrome Web Store manual review. - Use
activeTabinstead: It grants temporary, high-trust access to the currently focused tab only when the user explicitly interacts with the extension (clicking the toolbar icon, context menu, or keyboard shortcut).
// Requesting an optional permission at runtime
async function enableDownloadExport() {
const granted = await chrome.permissions.request({
permissions: ['downloads']
});
if (granted) {
chrome.downloads.download({
url: exportDataUrl,
filename: 'export.json'
});
}
}
Background Service Worker: Designing for Ephemerality
Because the MV3 service worker can terminate at any point between tasks, you must never store state in in-memory global variables.
Anti-Pattern: In-Memory Global State
// ❌ WRONG: State vanishes when the service worker sleeps
let activeSessionId = null;
chrome.runtime.onMessage.addListener((message) => {
if (message.action === 'START') {
activeSessionId = message.id;
} else if (message.action === 'GET') {
console.log(activeSessionId); // Frequently undefined
}
});
Best Practice: Persistent State with chrome.storage.session or local
Use chrome.storage.session (kept in-memory across the browser session) or chrome.storage.local (persisted to disk):
// src/background/index.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'SET_ACTIVE_SESSION') {
chrome.storage.session.set({ sessionId: message.payload }).then(() => {
sendResponse({ status: 'saved' });
});
return true; // Signals an asynchronous response
}
if (message.type === 'GET_ACTIVE_SESSION') {
chrome.storage.session.get('sessionId').then((data) => {
sendResponse({ sessionId: data.sessionId ?? null });
});
return true;
}
});
Note on Alarms: Avoid
setIntervalorsetTimeoutfor scheduling tasks longer than a few seconds. The worker will suspend regardless. Usechrome.alarmsto schedule periodic or delayed jobs reliably.
Content Scripts & Script Injection
Content scripts run in an isolated execution sandbox. They read and modify the host page’s DOM, attach mutation observers, and intercept user input, but cannot inspect JavaScript variables defined in the host page’s global window.
Static Injection vs Programmatic Injection
- Static Injection: Defined in
manifest.json. Automatically executes on matching URLs. - Programmatic Injection: Loaded dynamically via
chrome.scripting.executeScriptwhen triggered by user action:
// Executed from a background worker or popup
async function injectHighlighter(tabId) {
await chrome.scripting.executeScript({
target: { tabId: tabId },
func: () => {
document.body.style.border = '4px solid #3b82f6';
}
});
}
Bi-Directional Message Passing
Keep communication typed and deterministic between Content Scripts and Background:
// src/content/index.js
async function notifyBackgroundOfDomChange(payload) {
try {
const response = await chrome.runtime.sendMessage({
type: 'DOM_MUTATED',
payload: { elementCount: document.querySelectorAll('*').length }
});
console.log('Background acknowledged:', response);
} catch (error) {
// Handle edge case where background service worker is temporarily cold
console.error('Messaging failed:', error);
}
}
Shipping to the Chrome Web Store
Getting an extension approved quickly requires strict adherence to store guidelines and a clean build pipeline.
Pre-Submission Checklist
- Purge Remote Code: MV3 strictly bans remotely hosted code (
eval(), external script tags, CDN script imports). Every dependency must be bundled locally into the extension package. - Configure Content Security Policy (CSP): Ensure your CSP prevents inline script evaluation:
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
}
- Minimize Manifest Scopes: Justify every permission listed under
"permissions". During review, Google requires a clear explanation for high-impact permissions (e.g.,webRequest, broad host permissions). - Prepare Store Assets:
- Icon: 128x128 PNG (transparent background recommended).
- Promo Tiles: Small (440x280) and Marquee (1400x560).
- Screenshots: Standard 1280x800 or 640x400 without letterboxing.
- Clear Privacy Policy: If your extension handles any user input, tab URLs, or analytics, host an accessible public Privacy Policy detailing data handling (even if all data stays local on-device).
Packaging the Build
Ensure production builds exclude testing configs, source maps, development keys, and dotfiles:
# Example clean archive command
zip -r -FS ./dist/extension-v1.0.0.zip . -x "*.git*" "*node_modules*" "*tests*" "*.DS_Store" "src/dev-*"
Upload the resulting .zip via the Chrome Developer Dashboard. First-time submissions typically clear automated and manual review within 24 to 72 hours.