
Chrome Extension
- 2k installs
- 178 repo stars
- Updated August 1, 2026
- samber/cc-skills
chrome-extension is an agent skill that Comprehensive guide for building Chrome extensions with Manifest V3. Use this skill whenever the user mentions Chrome ex.
About
Chrome Extension Development Manifest V3 This skill covers everything needed to build debug and publish Chrome extensions with MV3 It is organized as a routing document read this file first to understand the architecture and decision points then load the relevant reference file for implementation details Read only the reference files relevant to the current task Each file is self contained File When to read references manifest v3 md Setting up or modifying manifest json configuring icons versioning references service worker md Background logic lifecycle state persistence alarms events references content scripts md Injecting code into pages isolated main world dynamic injection SPA handling orphaning references messaging rpc md Communication between any contexts typed protocols RPC layer async handler patterns references ui surfaces md Popup options page side panel context menus commands notifications omnibox devtools panel references storage md chrome storage local sync session quotas reactive patterns framework hooks references network csp md HTTP requests from content scripts CSP bypass relay declarativeNetRequest offscreen docs CORS references permissions md Required optional
- description: "Comprehensive guide for building Chrome extensions with Manifest V3. Use this skill whenever the user ment
- compatibility: Designed for Claude Code or similar AI coding agents. Requires git, node.
- homepage: https://github.com/samber/cc-skills
- Follow chrome-extension SKILL.md steps and documented constraints.
- Follow chrome-extension SKILL.md steps and documented constraints.
Chrome Extension by the numbers
- 1,966 all-time installs (skills.sh)
- +18 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #630 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
chrome-extension capabilities & compatibility
- Capabilities
- description: "comprehensive guide for building c · compatibility: designed for claude code or simil · homepage: https://github.com/samber/cc skills · follow chrome extension skill.md steps and docum
- Use cases
- orchestration
What chrome-extension says it does
description: "Comprehensive guide for building Chrome extensions with Manifest V3. Use this skill whenever the user mentions Chrome extension, browser extension, manifest.json, content script, service
compatibility: Designed for Claude Code or similar AI coding agents. Requires git, node.
homepage: https://github.com/samber/cc-skills
npx skills add https://github.com/samber/cc-skills --skill chrome-extensionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2k |
|---|---|
| repo stars | ★ 178 |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 1, 2026 |
| Repository | samber/cc-skills ↗ |
When should an agent use chrome-extension and what problem does it solve?
Comprehensive guide for building Chrome extensions with Manifest V3. Use this skill whenever the user mentions Chrome extension, browser extension, manifest.json, content script, service worker (in ex
Who is it for?
Developers invoking chrome-extension as documented in the skill source.
Skip if: Skip when requirements fall outside chrome-extension documented scope.
When should I use this skill?
Comprehensive guide for building Chrome extensions with Manifest V3. Use this skill whenever the user mentions Chrome extension, browser extension, manifest.json, content script, service worker (in ex
What you get
Outputs aligned with the chrome-extension SKILL.md workflow and stated deliverables.
- MV3 manifest and script structure
- Messaging and storage integration patterns
Files
Chrome Extension Development (Manifest V3)
This skill covers everything needed to build, debug, and publish Chrome extensions with MV3. It is organized as a routing document: read this file first to understand the architecture and decision points, then load the relevant reference file for implementation details.
Reference files
Read only the reference files relevant to the current task. Each file is self-contained.
| File | When to read |
|---|---|
references/manifest-v3.md | Setting up or modifying manifest.json, configuring icons, versioning |
references/service-worker.md | Background logic, lifecycle, state persistence, alarms, events |
references/content-scripts.md | Injecting code into pages, isolated/main world, dynamic injection, SPA handling, orphaning |
references/messaging-rpc.md | Communication between any contexts, typed protocols, RPC layer, async handler patterns |
references/ui-surfaces.md | Popup, options page, side panel, context menus, commands, notifications, omnibox, devtools panel |
references/storage.md | chrome.storage (local/sync/session), quotas, reactive patterns, framework hooks |
references/network-csp.md | HTTP requests from content scripts, CSP bypass relay, declarativeNetRequest, offscreen docs, CORS |
references/permissions.md | Required/optional permissions, host permissions, activeTab, runtime request flow |
references/web-accessible-resources.md | Exposing extension files to web pages, security implications |
references/typescript-build.md | TypeScript setup, project structure, build tools comparison, bundling |
references/publishing.md | Chrome Web Store submission, review process, rejection reasons, updates, privacy policy |
references/execution-contexts.md | Communication flow diagrams, per-context capabilities/limits, choosing the right messaging method |
references/debugging-mistakes.md | DevTools for extensions, testing SW termination, common gotchas, error patterns |
Architecture overview
A Chrome extension has up to 5 execution contexts that communicate via message passing:
┌──────────────────────────────────────────────────────────┐
│ Extension Process │
│ ┌─────────────────┐ ┌───────┐ ┌─────────┐ ┌──────┐ │
│ │ Service Worker │ │ Popup │ │ Options │ │ Side │ │
│ │ (background) │ │ │ │ Page │ │Panel │ │
│ │ - No DOM │ │ Full │ │ Full │ │ Full │ │
│ │ - Ephemeral │ │ DOM │ │ DOM │ │ DOM │ │
│ │ - All chrome.* │ │ All │ │ All │ │ All │ │
│ │ APIs │ │ APIs │ │ APIs │ │ APIs │ │
│ └────────┬─────────┘ └───┬───┘ └────┬────┘ └──┬───┘ │
│ │ chrome.runtime.sendMessage / connect │ │
└───────────┼────────────────┼───────────┼──────────┼──────┘
│ │ │ │
chrome.tabs.sendMessage │ │ │
│ │ │ │
┌───────────┼────────────────┼───────────┼──────────┼──────┐
│ Web Page ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Content Script │ │ Main World Script │ │
│ │ (isolated world) │◄──►│ (page context) │ │
│ │ - Shared DOM │ │ - Shared DOM │ │
│ │ - Own JS scope │ │ - Page JS scope │ │
│ │ - chrome.runtime │ │ - No chrome.* API │ │
│ │ - chrome.storage │ │ - Full page access│ │
│ │ - Subject to CSP │ │ - Subject to CSP │ │
│ │ (network only) │ │ (fully) │ │
│ └──────────────────┘ └──────────────────┘ │
│ ▲ window.postMessage │
│ │ (through shared DOM) │
└──────────────────────────────────────────────────────────┘Communication flows (labeled channels)
┌───────────────────────────────────────────────────────────────────────────┐
│ Extension Process │
│ │
│ ┌─────────────────┐ chrome.runtime ┌───────┐ ┌─────────┐ ┌──────┐ │
│ │ Service Worker │◄─.sendMessage()──│ Popup │ │ Options │ │ Side │ │
│ │ (background) │◄─.connect()──────│ │ │ Page │ │Panel │ │
│ │ │ └───────┘ └─────────┘ └──────┘ │
│ │ - No DOM │ ┌────────────────────────────────────────────┐ │
│ │ - Ephemeral 30s │ │ SW cannot push to these pages. │ │
│ │ - All chrome.* │ │ Use: ports (.connect) or storage.onChanged │ │
│ └────────┬─────────┘ └────────────────────────────────────────────┘ │
│ │ │
│ chrome.storage.onChanged ◄── fires across ALL contexts simultaneously │
│ │
└───────────┼──────────────────────────────────────────────────────────────┘
│ chrome.tabs.sendMessage(tabId, ...) [SW must know tabId]
│
┌───────────┼──────────────────────────────────────────────────────────────┐
│ Web Page ▼ │
│ ┌──────────────────┐ window.postMessage ┌──────────────────┐ │
│ │ Content Script │◄───────────────────►│ Main World Script │ │
│ │ (isolated world) │ Custom DOM events │ (page context) │ │
│ │ │ │ │ │
│ │ chrome.runtime ───┼── to/from SW │ No chrome.* APIs │ │
│ │ chrome.storage │ │ Full page JS │ │
│ │ Shared DOM │ │ Shared DOM │ │
│ │ Page CSP (network)│ │ Page CSP (full) │ │
│ └──────────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘For detailed flow diagrams (three-layer bridge, cross-extension, storage broadcast) and a per-context breakdown of permissions, limits, and workarounds: → Read references/execution-contexts.md
Communication methods at a glance
| Method | Direction | Best for |
|---|---|---|
chrome.runtime.sendMessage | Any ext context → SW | One-shot request/response (90% of cases) |
chrome.tabs.sendMessage | SW → content script (by tabId) | Pushing data to a specific tab |
chrome.runtime.connect (Port) | Bidirectional | Streaming, progress, SW ↔ popup |
window.postMessage | Between worlds on same page | Page JS ↔ content script bridge |
chrome.storage.onChanged | Broadcast to all contexts | Settings sync, no messaging needed |
→ Full matrix with limits and edge cases: references/execution-contexts.md → Implementation patterns, typed protocols, RPC layer: references/messaging-rpc.md
Key architectural rules
1. Service worker is ephemeral. It terminates after 30s of inactivity. All state must be persisted to chrome.storage. All event listeners must be registered synchronously at the top level. Never use setTimeout/setInterval for anything beyond a few seconds. → Read references/service-worker.md
2. Content scripts run in the page's origin. Network requests from content scripts are subject to the page's CSP and CORS. To bypass, relay through the service worker. → Read references/network-csp.md
3. Messaging is the backbone. Every cross-context interaction uses chrome.runtime messaging. The #1 bug: forgetting to return true from async message listeners. → Read references/messaging-rpc.md
4. Permissions determine CWS review speed. Broad host_permissions trigger manual review (weeks). activeTab + optional permissions = fast automated review. → Read references/permissions.md
5. Popup is destroyed on blur. Side panel persists. Choose based on interaction duration. → Read references/ui-surfaces.md
Decision tree: which context handles what?
"I need to run code when the user visits a page"
→ Content script. Static (manifest) for known URL patterns, dynamic (chrome.scripting) for user-triggered injection. Default to isolated world unless you need page JS access. → Read references/content-scripts.md
"I need to make an HTTP request to my API"
- From popup/options/side panel: direct fetch() works (extension origin, no CSP issues)
- From content script on a page with restrictive CSP: relay through service worker
- From service worker: direct fetch() works (requires host_permissions for the target domain) → Read
references/network-csp.md
"I need to store user settings"
- Settings that sync across devices: chrome.storage.sync (100KB limit)
- Large data or caches: chrome.storage.local (10MB, or unlimited with permission)
- Ephemeral state surviving SW restarts: chrome.storage.session → Read
references/storage.md
"I need to modify HTTP headers or block requests"
→ declarativeNetRequest (NOT webRequest, which lost blocking in MV3) → Read references/network-csp.md
"I need the page's JavaScript to talk to my extension"
→ Three-layer bridge: page (window.postMessage) → content script → service worker → Read references/messaging-rpc.md
"I need to understand what each context can and cannot do"
→ Read references/execution-contexts.md — per-context cards listing chrome.\* access, DOM, network, storage, lifetime, hard limits, and practical workarounds.
"I need periodic background tasks"
→ chrome.alarms (minimum 30s interval). NOT setTimeout. → Read references/service-worker.md
"I need DOM APIs in the background" (DOMParser, Canvas, Audio)
→ Offscreen document. One per extension, only chrome.runtime available. → Read references/network-csp.md
"I need to authenticate with OAuth"
→ chrome.identity.launchWebAuthFlow() or chrome.identity.getAuthToken() (Google only) → Read references/service-worker.md (identity section)
Workflow: new extension from scratch
1. Define the manifest with minimum permissions. Start with activeTab + scripting. → Read references/manifest-v3.md
2. Set up TypeScript and build tooling (or use CRXJS for Vite-based dev). → Read references/typescript-build.md
3. Implement the service worker with all event listeners at the top level. → Read references/service-worker.md
4. Add content scripts if you need page interaction. → Read references/content-scripts.md
5. Build UI surfaces (popup, options, side panel) as needed. → Read references/ui-surfaces.md
6. Wire up messaging between all contexts. → Read references/messaging-rpc.md
7. Test with DevTools, specifically test service worker termination. → Read references/debugging-mistakes.md
8. Publish to Chrome Web Store. → Read references/publishing.md
Workflow: adding a feature to an existing extension
1. Identify which context the feature belongs to (see decision tree above). 2. Read the relevant reference file(s) for that context. 3. Check if new permissions are needed. Prefer optional_permissions for new capabilities. → Read references/permissions.md 4. Update the manifest if adding new content scripts, UI surfaces, or permissions. 5. Handle extension updates gracefully (content script orphaning). → Read references/content-scripts.md (orphaning section)
Minimal manifest.json template
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "What it does in one sentence",
"permissions": ["storage", "activeTab", "scripting"],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}→ For the full manifest reference with all fields: references/manifest-v3.md
Code patterns quick reference
Async message handler (the safe pattern)
// Wrap async handlers to avoid the return-true trap
function asyncHandler(
fn: (msg: any, sender: chrome.runtime.MessageSender) => Promise<any>,
) {
return (
message: any,
sender: chrome.runtime.MessageSender,
sendResponse: (r: any) => void,
) => {
fn(message, sender)
.then(sendResponse)
.catch((e) => sendResponse({ __error: true, message: e.message }));
return true; // literal true, not Promise<true>
};
}
chrome.runtime.onMessage.addListener(
asyncHandler(async (msg, sender) => {
if (msg.type === "FETCH") {
const res = await fetch(msg.url);
return { ok: res.ok, data: await res.text() };
}
}),
);CSP bypass relay (content script → service worker → API)
// content-script.ts
async function apiCall(endpoint: string, options?: RequestInit) {
return chrome.runtime.sendMessage({ type: "API_RELAY", endpoint, options });
}
// background.ts
const ALLOWED_ENDPOINTS = ["https://api.example.com"];
chrome.runtime.onMessage.addListener(
asyncHandler(async (msg) => {
if (msg.type !== "API_RELAY") return;
if (!ALLOWED_ENDPOINTS.some((e) => msg.endpoint.startsWith(e))) {
throw new Error("Blocked endpoint");
}
const res = await fetch(msg.endpoint, msg.options);
return { ok: res.ok, status: res.status, data: await res.text() };
}),
);Persist state across SW restarts
// Use chrome.storage.session for ephemeral state
chrome.storage.session.setAccessLevel({
accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS",
});
async function getState<T>(key: string, fallback: T): Promise<T> {
const result = await chrome.storage.session.get(key);
return result[key] ?? fallback;
}
async function setState<T>(key: string, value: T): Promise<void> {
await chrome.storage.session.set({ [key]: value });
}Orphaned content script detection
function isExtensionContextValid(): boolean {
try {
return !!chrome.runtime?.id;
} catch {
return false;
}
}
// Before any chrome.runtime call
if (!isExtensionContextValid()) {
showRefreshBanner();
return;
}What NOT to do
- Do NOT use
eval(),new Function(), or load remote scripts. MV3 forbids it. - Do NOT use
setTimeout/setIntervalfor anything > 5s in service workers. - Do NOT register event listeners inside callbacks or async functions.
- Do NOT use
<all_urls>host permission unless absolutely necessary. - Do NOT rely on DevTools keeping the service worker alive during testing.
- Do NOT forget
return truein async message listeners. - Do NOT use
localStorageorsessionStoragein service workers (they don't exist there). - Do NOT assume content scripts survive extension updates.
- Do NOT use
webRequestblocking (removed in MV3). UsedeclarativeNetRequest. - Do NOT use
chrome.extension.getBackgroundPage()(removed in MV3).
Content Scripts Reference
Table of contents
1. Isolated world vs main world 2. Static declaration (manifest) 3. Dynamic/programmatic injection 4. Injection timing (run_at) 5. SPA navigation handling 6. Content script orphaning on extension update 7. CSS injection 8. Communication between worlds
1. Isolated world vs main world
Content scripts default to the isolated world: a separate JavaScript execution environment that shares the page's DOM but not its JavaScript objects.
| Capability | Isolated world | Main world |
|---|---|---|
| Read/modify DOM | Yes | Yes |
Access page JS variables (window.myApp) | No | Yes |
Intercept page fetch/XMLHttpRequest | No | Yes |
Access chrome.runtime | Yes | No |
Access chrome.storage | Yes | No |
Access chrome.i18n | Yes | No |
| Subject to page CSP (scripts) | No | Yes |
| Subject to page CSP (network) | Yes | Yes |
| Page can see script variables | No | Yes |
Can modify window prototypes | No | Yes |
When to use main world
- Intercepting/monkey-patching page functions (
fetch,XMLHttpRequest, custom APIs) - Reading page JavaScript state (SPA router state, React fiber tree)
- Setting
windowglobals for page scripts to consume - Overriding built-in APIs for instrumentation
When to use isolated world (default)
- Everything else. It's safer: the page can't tamper with your code, and you retain chrome.\* API access.
2. Static declaration (manifest)
{
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"exclude_matches": ["https://example.com/admin/*"],
"js": ["content/main.js", "content/utils.js"],
"css": ["content/styles.css"],
"run_at": "document_idle",
"all_frames": false,
"match_about_blank": false,
"world": "ISOLATED"
}
]
}Scripts in js array execute in order. CSS in css array is injected before any DOM.
3. Dynamic/programmatic injection
Use chrome.scripting (requires "scripting" permission) for on-demand injection. This is preferred over static when injection depends on user action or runtime conditions.
Execute a function
// Inject a function directly (serialized, no closure access)
chrome.scripting.executeScript({
target: { tabId, allFrames: false },
func: (color: string) => {
document.body.style.backgroundColor = color;
},
args: ["yellow"],
world: "ISOLATED", // or 'MAIN'
});Execute a file
chrome.scripting.executeScript({
target: { tabId },
files: ["content/injected.js"],
});Register persistent dynamic content scripts
Dynamic content scripts persist across browser restarts (unlike executeScript):
// Register (persists until explicitly removed)
await chrome.scripting.registerContentScripts([
{
id: "my-dynamic-script",
matches: ["https://example.com/*"],
js: ["content/dynamic.js"],
runAt: "document_idle",
persistAcrossSessions: true, // default true
},
]);
// Update
await chrome.scripting.updateContentScripts([
{
id: "my-dynamic-script",
excludeMatches: ["https://example.com/admin/*"],
},
]);
// List registered
const scripts = await chrome.scripting.getRegisteredContentScripts();
// Remove
await chrome.scripting.unregisterContentScripts({ ids: ["my-dynamic-script"] });CSS injection
// Insert CSS
await chrome.scripting.insertCSS({
target: { tabId },
css: "body { border: 2px solid red !important; }",
});
// Or from file
await chrome.scripting.insertCSS({
target: { tabId },
files: ["styles/highlight.css"],
});
// Remove CSS (same parameters as insert)
await chrome.scripting.removeCSS({
target: { tabId },
css: "body { border: 2px solid red !important; }",
});4. Injection timing (run_at)
| Value | When | DOM state | Use case |
|---|---|---|---|
document_start | Before any page scripts or DOM | Only <html> exists, no <body> | Monkey-patching APIs before page runs, blocking early scripts |
document_end | After DOM parsed, before subresources | Full DOM, images/iframes may still load | Most content modifications |
document_idle | After load event or small idle period | Everything loaded | Default. Safest. Use unless you need earlier access |
document_start gotchas
document.bodydoes not exist yet. Usedocument.documentElementor wait:
// At document_start, wait for body
const observer = new MutationObserver(() => {
if (document.body) {
observer.disconnect();
injectUI();
}
});
observer.observe(document.documentElement, { childList: true });- Script injection at document_start with
world: "MAIN"runs before ANY page scripts, making it ideal for API interception:
// main-world-early.js (run_at: document_start, world: MAIN)
const originalFetch = window.fetch;
window.fetch = async function (...args) {
console.log("Intercepted fetch:", args[0]);
return originalFetch.apply(this, args);
};5. SPA navigation handling
Single-page applications (YouTube, Gmail, Twitter) use History API for navigation. Content scripts only inject on full page loads, not SPA navigations.
Detection from service worker
// Detect SPA navigations
chrome.webNavigation.onHistoryStateUpdated.addListener((details) => {
if (details.frameId !== 0) return; // main frame only
chrome.tabs
.sendMessage(details.tabId, {
type: "SPA_NAVIGATION",
url: details.url,
})
.catch(() => {}); // Content script may not be ready
});
// Also handle hash changes
chrome.webNavigation.onReferenceFragmentUpdated.addListener((details) => {
if (details.frameId !== 0) return;
chrome.tabs
.sendMessage(details.tabId, {
type: "SPA_NAVIGATION",
url: details.url,
})
.catch(() => {});
});Detection from content script
// MutationObserver for DOM changes (SPA route transitions)
const observer = new MutationObserver((mutations) => {
// Check if significant content changed
for (const mutation of mutations) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
onContentChanged();
break;
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// URL change detection
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
onUrlChanged(lastUrl);
}
}).observe(document.body, { childList: true, subtree: true });
// Navigation API (Chrome 102+, more reliable)
if ("navigation" in window) {
navigation.addEventListener("navigatesuccess", () => {
onUrlChanged(location.href);
});
}6. Content script orphaning on extension update
When an extension updates, existing content scripts become orphaned: they continue running but lose access to chrome.runtime. All messaging throws errors.
Detection
function isExtensionAlive(): boolean {
try {
return !!chrome.runtime?.id;
} catch {
return false;
}
}
// Wrap every chrome.runtime call
function safeSendMessage(message: any): Promise<any> {
return new Promise((resolve, reject) => {
if (!isExtensionAlive()) {
reject(new Error("Extension context invalidated"));
showRefreshBanner();
return;
}
chrome.runtime.sendMessage(message, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve(response);
}
});
});
}Show refresh banner
function showRefreshBanner() {
if (document.getElementById("ext-refresh-banner")) return;
const banner = document.createElement("div");
banner.id = "ext-refresh-banner";
banner.innerHTML = `
<div style="position:fixed;top:0;left:0;right:0;z-index:999999;
background:#f59e0b;color:#000;padding:8px 16px;text-align:center;
font-family:system-ui;font-size:14px;">
Extension updated. Please <a href="#" style="color:#000;font-weight:bold;
text-decoration:underline;">refresh this page</a> to continue using it.
</div>
`;
banner.querySelector("a")!.addEventListener("click", (e) => {
e.preventDefault();
location.reload();
});
document.body.appendChild(banner);
}Re-inject on update (from service worker)
chrome.runtime.onInstalled.addListener(async (details) => {
if (details.reason !== "update") return;
const manifest = chrome.runtime.getManifest();
const tabs = await chrome.tabs.query({});
for (const cs of manifest.content_scripts ?? []) {
for (const tab of tabs) {
if (!tab.id || !tab.url) continue;
const matches = cs.matches?.some((pattern) =>
matchesPattern(tab.url!, pattern),
);
if (!matches) continue;
try {
if (cs.js) {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: cs.js,
});
}
if (cs.css) {
await chrome.scripting.insertCSS({
target: { tabId: tab.id },
files: cs.css,
});
}
} catch {
/* chrome:// pages, permission denied, etc. */
}
}
}
});Prevent double-injection
// content-script.ts (idempotent entry point)
(() => {
const MARKER = "__myext_v2_injected";
if ((window as any)[MARKER]) {
// Old instance exists; clean it up
(window as any).__myext_cleanup?.();
}
(window as any)[MARKER] = true;
const observer = new MutationObserver(/* ... */);
const ui = createUI();
(window as any).__myext_cleanup = () => {
observer.disconnect();
ui.remove();
delete (window as any)[MARKER];
};
})();7. CSS injection patterns
Shadow DOM for isolated UI
Inject UI that won't be affected by the page's CSS and vice versa:
function createIsolatedUI() {
const host = document.createElement("div");
host.id = "myext-root";
const shadow = host.attachShadow({ mode: "closed" });
shadow.innerHTML = `
<style>
:host { all: initial; }
.panel { position: fixed; bottom: 20px; right: 20px; z-index: 2147483647;
background: white; border-radius: 8px; padding: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-family: system-ui; }
button { padding: 8px 16px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; }
</style>
<div class="panel">
<p>My Extension</p>
<button id="action-btn">Do thing</button>
</div>
`;
shadow.getElementById("action-btn")!.addEventListener("click", () => {
chrome.runtime.sendMessage({ type: "ACTION" });
});
document.body.appendChild(host);
return { host, shadow };
}Using extension CSS files
// Load CSS from extension bundle
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = chrome.runtime.getURL("styles/content.css");
document.head.appendChild(link);Requires the CSS file to be listed in web_accessible_resources.
8. Communication between worlds
Content scripts in isolated world and main world scripts on the same page communicate through the shared DOM via window.postMessage or custom DOM events.
window.postMessage (bidirectional)
// Main world script
window.postMessage(
{ source: "MY_EXT_PAGE", type: "DATA", payload: window.appState },
"*",
);
// Content script (isolated world)
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.source !== "MY_EXT_PAGE") return;
chrome.runtime.sendMessage(event.data);
});Custom DOM events (more targeted)
// Main world: dispatch custom event
document.dispatchEvent(
new CustomEvent("myext-data", {
detail: JSON.parse(JSON.stringify(window.appState)), // must be cloneable
}),
);
// Content script: listen
document.addEventListener("myext-data", (event: CustomEvent) => {
chrome.runtime.sendMessage({ type: "PAGE_DATA", data: event.detail });
});Custom events are slightly more targeted than postMessage (no cross-origin concerns) but require serializable data via JSON.parse(JSON.stringify(...)).
Debugging and Common Mistakes Reference
Table of contents
1. DevTools for each extension context 2. Testing service worker termination 3. Common error messages and fixes 4. The top 10 MV3 mistakes 5. Performance pitfalls 6. Testing strategies
1. DevTools for each extension context
Each extension context has its own DevTools instance:
| Context | How to open DevTools |
|---|---|
| Service worker | chrome://extensions → click "Inspect views: service worker" |
| Popup | Right-click extension icon → "Inspect popup" |
| Options page | Right-click in options page → "Inspect" |
| Side panel | Right-click in side panel → "Inspect" |
| Content script | Normal page DevTools (F12), check Sources → Content scripts |
| Offscreen document | chrome://extensions → "Inspect views: offscreen.html" |
Content script debugging tips
Content script console logs appear in the PAGE's DevTools console, not the extension's. Filter by your extension name or use console.log('[MyExt]', ...) prefix.
In Sources panel: look under "Content scripts" folder to set breakpoints.
Service worker debugging
The service worker console is separate. Use chrome://extensions → Inspect to open it. Warning: keeping DevTools open on the SW prevents it from terminating, masking lifecycle bugs.
2. Testing service worker termination
Manual termination
1. Open chrome://extensions 2. Find your extension 3. Click the "service worker" link to open DevTools (optional) 4. Click the "Stop" button next to the service worker link 5. Trigger your extension's functionality 6. Verify it works after the SW restarts
Programmatic self-termination for testing
// Add a test-only handler
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === "__TEST_TERMINATE_SW") {
// Force terminate by not returning true and doing nothing
// The SW will idle out in 30 seconds
// OR: more aggressively, just stop doing anything
}
});Chrome flag for testing
chrome://flags/#enable-extension-service-worker-test-flag (if available in your Chrome version) can help with automated testing of SW suspension.
Automated test pattern
// In a test (e.g., Puppeteer/Playwright)
// 1. Load extension
// 2. Interact with it (verify functionality)
// 3. Wait 35 seconds (or stop the SW via chrome://extensions)
// 4. Interact again (verify it recovers)
// 5. Check that all state was preserved3. Common error messages and fixes
"Extension context invalidated"
Cause: Content script trying to use chrome.runtime after extension was updated/reloaded. Fix: Check chrome.runtime?.id before any chrome.runtime call. Show refresh banner.
"Could not establish connection. Receiving end does not exist."
Causes:
1. No content script in the target tab 2. Content script orphaned 3. Sending to chrome:// or other restricted page 4. Service worker terminated and listener not registered
Fix: Wrap in try/catch. Check tab URL before sending. Verify content script injection.
"The message port closed before a response was received."
Cause: Message listener didn't call sendResponse and didn't return true. Fix: return true from the listener if response is async.
"Unchecked runtime.lastError: ..."
Cause: chrome.\ API callback error not checked. Fix*: always check chrome.runtime.lastError in callbacks, or use Promise-based APIs and catch rejections:
// Callback style
chrome.tabs.sendMessage(tabId, msg, (response) => {
if (chrome.runtime.lastError) {
console.warn(chrome.runtime.lastError.message);
return;
}
// use response
});
// Promise style (preferred)
try {
const response = await chrome.tabs.sendMessage(tabId, msg);
} catch (err) {
console.warn("Tab unreachable:", err);
}"Cannot access contents of url ... Extension manifest must request permission."
Cause: missing host_permissions for the target URL. Fix: add the origin to host_permissions or optional_host_permissions.
"Service worker registration failed."
Causes:
1. Syntax error in the service worker file 2. Import of a nonexistent module 3. Top-level await with incorrect module config 4. Missing "type": "module" when using import statements
Fix: check the error details in chrome://extensions. Fix syntax errors. Ensure "type": "module" is set if using ES imports.
4. The top 10 MV3 mistakes
1. Storing state in global variables
Global variables reset when SW terminates. Use chrome.storage.session instead.
2. Using setTimeout/setInterval in the service worker
Cancelled on termination. Use chrome.alarms (30s minimum interval).
3. Forgetting return true in async message listeners
The channel closes before sendResponse fires. Use the asyncHandler wrapper.
4. Using async directly on message listeners
Async functions return Promise, not literal true. Wrap in synchronous function.
5. Registering listeners inside callbacks
Chrome records listener registrations at SW startup. Listeners registered inside callbacks, promises, or dynamic imports are missed after restart.
6. Testing with DevTools open (hides SW termination)
DevTools keeps the SW alive indefinitely. Always test with DevTools closed.
7. Requesting excessive permissions
Slows CWS review, scares users. Use activeTab + optional_permissions.
8. Not handling content script orphaning
After extension update, old content scripts lose chrome.runtime. Always check chrome.runtime?.id and show a refresh prompt.
9. Confusing CORS and CSP
CORS is server-side (who can read responses). CSP is page-side (what can connect). Content scripts are subject to both. Service worker bypasses both with host_permissions.
10. Using eval() or loading remote code
Forbidden in MV3. All code must be bundled locally. Use chrome.scripting.executeScript with func parameter instead of code string.
5. Performance pitfalls
Memory leaks in content scripts
Content scripts persist as long as the page is open. Common leaks:
// ❌ LEAK: never cleaned up
document.addEventListener("scroll", onScroll);
const observer = new MutationObserver(callback);
observer.observe(document.body, { childList: true, subtree: true });
// ✅ CLEAN UP
function destroy() {
document.removeEventListener("scroll", onScroll);
observer.disconnect();
ui?.remove();
}
// Clean up on extension update
chrome.runtime.onConnect.addListener(() => {});
// If this throws, extension was updated
try {
chrome.runtime.id;
} catch {
destroy();
}Storage performance
// ❌ SLOW: many small reads
for (const key of keys) {
const value = await chrome.storage.local.get(key);
}
// ✅ FAST: batch read
const values = await chrome.storage.local.get(keys);Service worker startup time
Keep the service worker entry point lean. Heavy initialization blocks event handling:
// ❌ SLOW: heavy computation at startup
const bigLookupTable = computeExpensiveTable(); // blocks all events
chrome.runtime.onMessage.addListener(handleMessage);
// ✅ FAST: lazy initialization
let lookupTable: Map<string, any> | null = null;
async function getTable() {
if (!lookupTable) {
const cached = await chrome.storage.session.get("lookupTable");
lookupTable = cached.lookupTable
? new Map(cached.lookupTable)
: await computeTable();
}
return lookupTable;
}
chrome.runtime.onMessage.addListener(handleMessage);6. Testing strategies
Manual testing checklist
1. Load unpacked at chrome://extensions 2. Test all user flows with DevTools CLOSED 3. Stop the service worker manually, then test again 4. Update the extension (increment version, reload), verify content scripts recover 5. Test on restricted pages (chrome://, chrome-extension://, about:blank) 6. Test with permissions revoked (chrome://settings → Privacy → Site Settings) 7. Test incognito mode (if incognito: "spanning" in manifest)
Automated testing with Puppeteer
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
headless: false,
args: [
`--disable-extensions-except=/path/to/extension`,
`--load-extension=/path/to/extension`,
],
});
// Get extension ID
const targets = await browser.targets();
const extensionTarget = targets.find((t) => t.type() === "service_worker");
const extensionUrl = extensionTarget?.url() ?? "";
const extensionId = extensionUrl.split("/")[2];
// Test popup
const popupPage = await browser.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
// ... assert popup contentUnit testing shared code
Shared utilities (message types, storage helpers, pure functions) can be tested with any standard test runner (Vitest, Jest) without a browser:
// shared/utils.test.ts
import { debounce, formatTimestamp } from "./utils";
test("debounce delays execution", async () => {
/* ... */
});Mock chrome.\* APIs for integration tests:
// test/mocks/chrome.ts
globalThis.chrome = {
storage: {
local: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
},
},
runtime: {
sendMessage: vi.fn(),
onMessage: { addListener: vi.fn() },
},
} as any;Execution Contexts, Communication Flows, and Limits
Table of contents
1. Communication flow diagrams 2. Communication methods matrix 3. Per-context reference cards
1. Communication flow diagrams
Full picture: all contexts and channels
Extension Process
┌──────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────┐ chrome.runtime.sendMessage() │
│ │ Service Worker │◄──────────────────────────────────┐ │
│ │ (background) │───────────────────────────────┐ │ │
│ └──────────┬───────────┘ │ │ │
│ ▲ │ │ │ │
│ │ │ chrome.tabs.sendMessage(tabId, ...) │ │ │
│ │ │ ▼ │ │
│ │ │ ┌────────┐ ┌─────────┐ ┌──────────┐ │ │
│ │ │ │ Popup │ │ Options │ │ Side │ │ │
│ │ │ │ │ │ Page │ │ Panel │ │ │
│ │ │ └───┬────┘ └────┬────┘ └────┬─────┘ │ │
│ │ │ │ │ │ │ │
│ │ │ └────────────┴─────────────┘ │ │
│ │ │ chrome.runtime.sendMessage() │ │
│ │ │ chrome.runtime.connect() │ │
│ │ │ │ │
│ chrome.storage.onChanged ◄──── fires across ALL contexts ─┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
│
│ chrome.tabs.sendMessage(tabId, ...)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Web Page (per tab) │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Content Script │ │ Main World Script │ │
│ │ (isolated world) │ │ (page context) │ │
│ │ │◄───►│ │ │
│ │ chrome.runtime ──────┼──► │ No chrome.* APIs │ │
│ │ chrome.storage │ │ Full page JS access │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ ▲ │ ▲ │ │
│ │ │ │ │ │
│ │ window.postMessage() │ window.postMessage() │
│ │ Custom DOM events │ Custom DOM events │
│ │ │ │ │ │
│ └────────┘ └────────┘ │
│ Shared DOM (read/write by both worlds) │
└──────────────────────────────────────────────────────────────────┘Extension-internal messaging (popup/options/sidepanel to service worker)
All extension UI pages share the same origin and use the same API to reach the service worker.
┌────────┐ ┌─────────┐ ┌───────────┐
│ Popup │ │ Options │ │ Side Panel│
└───┬────┘ └────┬────┘ └─────┬─────┘
│ │ │
│ chrome.runtime.sendMessage({ type, payload })
│ chrome.runtime.connect({ name })
▼ ▼ ▼
┌──────────────────────────────────────┐
│ Service Worker │
│ chrome.runtime.onMessage.addListener│
│ chrome.runtime.onConnect.addListener│
└──────────────────────────────────────┘
│
│ (cannot push to popup/options/sidepanel
│ via tabs.sendMessage — they are not tabs)
│
│ Workaround: use chrome.storage.onChanged
│ or long-lived port (chrome.runtime.connect)
▼Key point: The service worker cannot initiate messages to popup/options/sidepanel with sendMessage. Use ports (bidirectional) or storage change events for SW-to-UI-page communication.
Content script to service worker (and back)
┌──────────────────────┐ ┌─────────────────────┐
│ Content Script │ │ Service Worker │
│ (in tab) │ │ │
│ │ ──────────► │ │
│ chrome.runtime │ sendMessage() │ onMessage listener │
│ .sendMessage() │ │ │
│ │ ◄────────── │ │
│ (receives response │ sendResponse()│ │
│ via callback/await) │ │ │
│ │ │ │
│ chrome.runtime │ ◄────────── │ chrome.tabs │
│ .onMessage listener│ tabs.send │ .sendMessage() │
│ │ Message() │ │
└──────────────────────┘ └─────────────────────┘The SW must know the tabId to push messages to a content script. It does not know which tabs have content scripts unless it tracks them (via chrome.tabs.query or by content scripts registering on load).
Three-layer bridge: web page to extension
When the page's own JavaScript (no chrome.\* access) needs to talk to the extension:
┌────────────────┐ window.postMessage ┌────────────────┐ chrome.runtime ┌───────────────┐
│ Page JS │ ────────────────────► │ Content Script │ ──────────────────► │ Service Worker │
│ (main world) │ │ (isolated world)│ │ │
│ │ ◄──────────────────── │ (bridge/relay) │ ◄────────────────── │ │
│ No chrome.* API │ window.postMessage │ chrome.runtime │ sendResponse() │ Full chrome.* │
└────────────────┘ └────────────────┘ └───────────────┘
Direction key:
Page ──► CS : window.postMessage({ channel, direction: 'TO_EXT', ... })
CS ──► SW : chrome.runtime.sendMessage({ type, payload })
SW ──► CS : sendResponse() (reply) or chrome.tabs.sendMessage() (push)
CS ──► Page: window.postMessage({ channel, direction: 'FROM_EXT', ... })Use a unique channel string and direction field to filter messages. Always validate event.source === window on the receiving side.
Cross-extension messaging
┌────────────────────┐ ┌────────────────────┐
│ Extension A │ chrome.runtime │ Extension B │
│ │ .sendMessage( │ │
│ (sender) │ extB_id, msg) │ (receiver) │
│ │ ──────────────────► │ │
│ │ │ onMessageExternal │
│ │ ◄────────────────── │ .addListener() │
│ │ sendResponse() │ │
└────────────────────┘ └────────────────────┘
Requires in Extension B's manifest:
"externally_connectable": { "ids": ["<ext-A-id>"] }Implicit communication via storage
┌────────┐ ┌─────────┐ ┌──────────┐ ┌────────────┐ ┌────────────────┐
│ Popup │ │ Options │ │ SW │ │ Side Panel │ │ Content Script │
└───┬────┘ └────┬────┘ └────┬─────┘ └─────┬──────┘ └───────┬────────┘
│ │ │ │ │
│ chrome.storage.local.set({ theme: 'dark' }) │
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ chrome.storage.onChanged fires in ALL contexts simultaneously │
│ No explicit messaging needed for settings/state propagation │
└──────────────────────────────────────────────────────────────────────┘This is the simplest way to keep all contexts in sync. Any context writes, all others react.
2. Communication methods matrix
| Method | Direction | Use case | Response? | Keeps SW alive? | Size limit |
|---|---|---|---|---|---|
chrome.runtime.sendMessage | Any ext context -> SW | One-shot request/response | Yes (via sendResponse) | Resets 30s timer | ~64 MB |
chrome.tabs.sendMessage | SW -> content script (by tabId) | Push data to a specific tab | Yes (via sendResponse) | Resets 30s timer | ~64 MB |
chrome.runtime.connect (Port) | Bidirectional, any ext context | Streaming, progress, real-time | Continuous | Yes, while active | ~64 MB/msg |
window.postMessage | Between worlds on same page | Page JS <-> content script | No (use request IDs) | N/A | Structured clone |
| Custom DOM events | Between worlds on same page | Targeted, no cross-origin | No | N/A | JSON-serializable |
chrome.storage.onChanged | Broadcast to all contexts | Settings sync, state propagation | No (fire-and-forget) | Wakes SW | Per storage area |
externally_connectable | Extension <-> extension | Cross-extension RPC | Yes (via sendResponse) | Resets 30s timer | ~64 MB |
| Shared DOM | Content script <-> main world | Read/write DOM elements | N/A | N/A | DOM size |
When to use which
- Simple request/response:
sendMessage(one-shot, covers 90% of cases) - SW pushes to content script:
tabs.sendMessage(requires knowing tabId) - SW pushes to popup/sidepanel: storage change events or ports
- Streaming/progress: ports (
chrome.runtime.connect) - Page JS to extension: three-layer bridge via
window.postMessage+ content script relay - Settings sync across all contexts:
chrome.storage.onChanged(no messaging code needed)
3. Per-context reference cards
Service Worker (background)
| Capability | Status |
|---|---|
| DOM access | None |
| chrome.\* APIs | All (full API surface) |
fetch() | Yes, bypasses CORS with host_permissions |
| Subject to page CSP | No |
localStorage / sessionStorage | Not available |
XMLHttpRequest | Not available (fetch only) |
| Dynamic code generation | Forbidden by MV3 |
| IndexedDB | Yes |
chrome.storage.* | All areas (local, sync, session) |
Lifetime: Ephemeral. Terminates after 30s of inactivity. Hard cap of 5 minutes for any single task.
Hard limits and workarounds:
| Limit | Impact | Workaround |
|---|---|---|
| 30s idle termination | Global variables lost, timers cancelled | Persist state to chrome.storage.session; use chrome.alarms instead of setTimeout |
| 5-minute hard cap | Long tasks killed | Break into alarm-driven steps; delegate to offscreen document |
| No DOM | Cannot use DOMParser, Canvas, Audio | Create offscreen document with appropriate Reason |
| No localStorage/sessionStorage | Cannot use libraries that depend on them | Use chrome.storage.session (same purpose, survives SW restarts) |
| No dynamic code generation | Cannot load remote scripts | Bundle all code at build time |
| Event listeners must be top-level | Listeners inside callbacks are lost on restart | Always register synchronously at module scope; use static imports |
| Cannot push to popup/sidepanel | No tabs.sendMessage for extension pages | Use ports or chrome.storage.onChanged |
Popup
| Capability | Status |
|---|---|
| DOM access | Full (own document) |
| chrome.\* APIs | All |
fetch() | Yes, same as SW (extension origin) |
| Subject to page CSP | No (extension CSP applies) |
localStorage | Available but discouraged (not shared, lost on reinstall) |
| Dynamic code generation | Forbidden by MV3 extension CSP |
chrome.storage.* | All areas |
Lifetime: Destroyed on blur (clicking outside the popup). No state survives between opens.
Hard limits and workarounds:
| Limit | Impact | Workaround |
|---|---|---|
| Destroyed on blur | All JS state and DOM lost | Persist any important state to chrome.storage before/during interaction |
| Small viewport (800x600 max) | Limited UI real estate | Use side panel for complex UIs that need persistence |
| Cannot be opened programmatically | SW cannot show the popup | Use chrome.action.openPopup() (Chrome 127+, requires user gesture) or notifications |
| Extension CSP blocks inline scripts | No <script> tags in HTML, no inline event handlers | Use separate .js files; add listeners via addEventListener |
Options Page
| Capability | Status |
|---|---|
| DOM access | Full (own document) |
| chrome.\* APIs | All |
fetch() | Yes (extension origin) |
localStorage | Available but discouraged |
chrome.storage.* | All areas |
Lifetime: Persistent while tab is open. Survives as long as the user keeps the tab.
Hard limits and workarounds:
| Limit | Impact | Workaround |
|---|---|---|
| Extension CSP blocks inline scripts | Same as popup | Use separate .js files |
| Dynamic code generation forbidden | Same as popup | Bundle at build time |
| No special limits | Options page is a regular extension page | N/A |
Side Panel
| Capability | Status |
|---|---|
| DOM access | Full (own document) |
| chrome.\* APIs | All |
fetch() | Yes (extension origin) |
chrome.storage.* | All areas |
Lifetime: Persists while panel is open. Survives navigation in the main tab (unlike popup). Can be global or per-tab.
Hard limits and workarounds:
| Limit | Impact | Workaround |
|---|---|---|
| One side panel per extension | Cannot show multiple panels | Use tabbed UI within the panel |
| Extension CSP blocks inline scripts | Same as popup | Use separate .js files |
| Chrome 114+ only | Not available on older browsers | Feature-detect with chrome.sidePanel check; fall back to popup |
Content Script (Isolated World) — default
| Capability | Status |
|---|---|
| DOM access | Full (shared with page) |
| chrome.\* APIs | Limited: runtime, storage, i18n only |
fetch() | Subject to page's CSP connect-src and CORS |
| Page JS variables | Not accessible (separate JS scope) |
localStorage | Page's localStorage (not extension's) |
chrome.storage.* | local: yes, sync: yes, session: only if access level set |
Lifetime: Lives as long as the page. Orphaned on extension update (loses chrome.runtime).
Hard limits and workarounds:
| Limit | Impact | Workaround |
|---|---|---|
| Page CSP blocks fetch to external APIs | Cannot call your API directly | Relay through service worker (the standard pattern) |
| No chrome.tabs, chrome.scripting, etc. | Cannot manage tabs or inject into other pages | Send message to SW, let SW handle it |
| Orphaned on extension update | All chrome.runtime calls throw | Check chrome.runtime?.id before calls; show refresh banner |
| Cannot access page JS variables | Cannot read SPA state, intercept fetch | Inject a main-world script and bridge via postMessage |
| Page can't see content script variables | Page JS cannot call your functions | Use window.postMessage or custom DOM events |
| Subject to page CORS | Cross-origin fetch may fail even if CSP allows it | Relay through SW (SW with host_permissions bypasses CORS) |
Content Script (Main World)
| Capability | Status |
|---|---|
| DOM access | Full (shared with page) |
| chrome.\* APIs | None |
fetch() | Subject to page's full CSP and CORS |
| Page JS variables | Full access (same scope as page) |
localStorage | Page's localStorage |
chrome.storage.* | Not available |
Lifetime: Same as page. Runs in the page's JS context.
Hard limits and workarounds:
| Limit | Impact | Workaround |
|---|---|---|
| No chrome.\* APIs at all | Cannot message SW, cannot use storage | Bridge through isolated-world content script via postMessage/DOM events |
| Subject to full page CSP | Cannot load external scripts, restricted fetch | All code must be bundled; relay network through content script -> SW |
| Page can tamper with your code | Page can override prototypes, intercept calls | Capture references to builtins at document_start before page runs |
| No direct extension storage | Cannot persist data | Send data to content script (isolated) via postMessage, which writes to chrome.storage |
Offscreen Document
| Capability | Status |
|---|---|
| DOM access | Full (own document, not visible) |
| chrome.\* APIs | chrome.runtime only |
fetch() | Yes (extension origin, bypasses page CSP) |
localStorage | Available (extension origin) |
| Canvas, Audio, DOMParser | All available |
chrome.storage.* | Not directly (only via messaging to SW) |
Lifetime: Created on demand, persists until closed or browser restart. One per extension.
Hard limits and workarounds:
| Limit | Impact | Workaround |
|---|---|---|
| Only chrome.runtime API | Cannot use tabs, scripting, etc. | Message the SW for anything beyond runtime |
| One per extension | Cannot run multiple offscreen tasks in parallel | Multiplex: use message types to route different tasks to the same document |
| Must specify a Reason enum | Chrome validates the reason matches usage | Pick the correct Reason (DOM_PARSER, AUDIO_PLAYBACK, CLIPBOARD, etc.) |
| Not visible to user | Cannot render UI | Only for background DOM work; use popup/sidepanel for UI |
Manifest V3 Complete Reference
Table of contents
1. Required fields 2. All manifest fields 3. Icons 4. Versioning 5. Content Security Policy 6. Web accessible resources 7. Minimal vs full manifest examples
1. Required fields
Every MV3 extension must declare these three fields:
{
"manifest_version": 3,
"name": "Extension Name (max 45 chars)",
"version": "1.0.0"
}version must be 1-4 dot-separated integers (e.g., 1.0.0.1). Use version_name for human-readable display (e.g., "1.0 beta").
2. All manifest fields
{
// === REQUIRED ===
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
// === RECOMMENDED ===
"description": "One sentence, max 132 chars for CWS",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
// === BACKGROUND ===
"background": {
"service_worker": "background.js",
"type": "module" // enables ES import/export
},
// === CONTENT SCRIPTS ===
"content_scripts": [
{
"matches": ["https://*.example.com/*"],
"js": ["content.js"],
"css": ["content.css"],
"run_at": "document_idle", // document_start | document_end | document_idle
"all_frames": false, // inject into iframes too?
"match_about_blank": false, // inject into about:blank frames?
"match_origin_as_fallback": false, // inject into blob:/data: frames?
"world": "ISOLATED", // ISOLATED | MAIN
"exclude_matches": ["*://example.com/admin/*"],
"include_globs": [],
"exclude_globs": []
}
],
// === UI SURFACES ===
"action": {
"default_popup": "popup.html",
"default_icon": { "16": "icon16.png", "48": "icon48.png" },
"default_title": "Click to open"
},
"options_page": "options.html", // full-page options
"options_ui": {
// embedded in chrome://extensions
"page": "options.html",
"open_in_tab": false
},
"side_panel": {
"default_path": "sidepanel.html"
},
"devtools_page": "devtools.html",
// === PERMISSIONS ===
"permissions": [
"storage", // chrome.storage
"activeTab", // temporary access to current tab on user gesture
"scripting", // chrome.scripting.executeScript
"alarms", // chrome.alarms
"contextMenus", // chrome.contextMenus
"notifications", // chrome.notifications
"sidePanel", // chrome.sidePanel
"offscreen", // chrome.offscreen
"tabs", // chrome.tabs (url, title, favIconUrl access)
"identity", // chrome.identity (OAuth)
"cookies", // chrome.cookies
"webNavigation", // chrome.webNavigation
"declarativeNetRequest", // Rule-based network modification
"declarativeNetRequestWithHostAccess" // Same + host permission check
],
"optional_permissions": ["bookmarks", "history", "downloads"],
"host_permissions": ["https://api.example.com/*"],
"optional_host_permissions": ["https://*/*"],
// === NETWORK RULES ===
"declarative_net_request": {
"rule_resources": [
{
"id": "ruleset_1",
"enabled": true,
"path": "rules.json"
}
]
},
// === WEB ACCESSIBLE RESOURCES ===
"web_accessible_resources": [
{
"resources": ["images/*.png", "styles/injected.css"],
"matches": ["https://*.example.com/*"]
},
{
"resources": ["worker.js"],
"matches": ["<all_urls>"],
"use_dynamic_url": true // randomized URL, prevents fingerprinting
}
],
// === COMMANDS (keyboard shortcuts) ===
"commands": {
"_execute_action": {
// built-in: triggers action click
"suggested_key": { "default": "Ctrl+Shift+Y", "mac": "Command+Shift+Y" },
"description": "Open popup"
},
"toggle-feature": {
"suggested_key": { "default": "Ctrl+Shift+F" },
"description": "Toggle the feature"
}
},
// === INTERNATIONALIZATION ===
"default_locale": "en", // required if _locales/ exists
// === CONTENT SECURITY POLICY ===
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'",
"sandbox": "sandbox allow-scripts; script-src 'self' 'unsafe-eval'"
},
// === OTHER ===
"homepage_url": "https://example.com",
"short_name": "Ext", // max 12 chars, used when space is limited
"author": "Your Name",
"version_name": "1.0 beta", // human-readable, not used for updates
"minimum_chrome_version": "116",
"incognito": "spanning", // spanning | split | not_allowed
"sandbox": {
"pages": ["sandbox.html"] // pages with relaxed CSP (allow eval)
},
"externally_connectable": {
"matches": ["https://*.example.com/*"],
"ids": ["other_extension_id"] // allow cross-extension messaging
},
"chrome_url_overrides": {
"newtab": "newtab.html" // or "bookmarks" or "history"
},
"omnibox": { "keyword": "myext" },
"update_url": "https://example.com/updates.xml" // enterprise self-hosting
}3. Icons
Provide at least 16, 48, and 128px PNG icons. Chrome uses them in different contexts:
| Size | Used in |
|---|---|
| 16px | Toolbar, favicon, context menus |
| 32px | Windows taskbar (2x of 16) |
| 48px | Extensions management page |
| 128px | Chrome Web Store, installation dialog |
SVG is NOT supported in manifest icon fields. Use PNG with transparency.
For the action icon specifically, provide 16 and 32 (or 19 and 38 for exact toolbar sizing). Chrome auto-scales, but crisp icons at native sizes look best.
4. Versioning
version: 1-4 integers separated by dots.1.0<1.1<2.0. Used for auto-updates.- Published version must always be higher than the previous CWS version.
- CWS compares numerically:
1.10.0>1.9.0. version_nameis cosmetic only (display in chrome://extensions).
5. Content Security Policy (extension pages)
MV3 extensions cannot use unsafe-eval or unsafe-inline in extension_pages CSP. The default is already restrictive:
script-src 'self'; object-src 'self'You can tighten it but not relax it (no remote scripts, no inline scripts, no eval).
For sandbox pages, unsafe-eval IS allowed (useful for template engines):
"content_security_policy": {
"sandbox": "sandbox allow-scripts allow-forms; script-src 'self' 'unsafe-eval'"
}Sandbox pages have no access to chrome.\* APIs.
6. Web accessible resources (MV3 changes)
In MV3, web_accessible_resources require explicit match patterns. No more global access:
"web_accessible_resources": [{
"resources": ["inject.js", "styles.css"],
"matches": ["https://example.com/*"] // only this origin can access
}]Access via: chrome.runtime.getURL('inject.js') returns chrome-extension://<id>/inject.js.
Set use_dynamic_url: true to prevent fingerprinting (URL changes each session).
7. Match patterns syntax
<scheme>://<host><path>
Scheme: http | https | file | ftp | * (http+https) | chrome-extension
Host: *.example.com | example.com | * (any host)
Path: /* | /path/* | /specific/page.html
Special: <all_urls> = matches everything (avoid for CWS approval)Examples:
https://*.google.com/*- all Google subdomains over HTTPS*://example.com/api/*- HTTP or HTTPS, specific pathfile:///*- local files (requires user opt-in)http://localhost:*/*- localhost any port
Messaging and RPC Reference
Table of contents
1. One-shot messaging (sendMessage) 2. The async handler trap and fix 3. Port-based long-lived connections 4. Service worker → content script 5. Full three-layer bridge: page ↔ content script ↔ service worker 6. Typed message protocol with discriminated unions 7. Full RPC layer (simulate HTTP through messaging) 8. Cross-extension messaging 9. Common messaging bugs
1. One-shot messaging (sendMessage)
The simplest pattern: send a message, get one response.
// Content script or popup → service worker
const response = await chrome.runtime.sendMessage({
type: "GET_DATA",
key: "user",
});
// Service worker → specific tab's content script
await chrome.tabs.sendMessage(tabId, {
type: "HIGHLIGHT",
selector: ".target",
});
// Service worker handler
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// sender.tab exists if message came from a content script
// sender.url is the page URL or extension page URL
// sender.id is the extension ID
if (message.type === "GET_DATA") {
chrome.storage.local.get(message.key).then(sendResponse);
return true; // CRITICAL for async responses
}
});Response rules
- Synchronous response: return value doesn't matter, call
sendResponse()before listener returns - Async response: MUST
return truefrom the listener (literal boolean, not a Promise) - No response needed: return
undefinedorfalse(channel closes immediately) - Multiple listeners: only the FIRST listener to call
sendResponseorreturn truewins. Other listeners are ignored.
2. The async handler trap and fix
This is the #1 messaging bug in Chrome extension development.
The problem
async functions always return a Promise. Chrome checks for literal true, not Promise<true>.
// ❌ BROKEN: async returns Promise, Chrome closes channel immediately
chrome.runtime.onMessage.addListener(async (msg, sender, sendResponse) => {
const data = await fetch("https://api.example.com/data");
const json = await data.json();
sendResponse(json); // NEVER REACHES the sender
return true; // Wrapped in Promise<true>, Chrome doesn't see it
});The fix: non-async wrapper
// ✅ CORRECT: synchronous wrapper returns literal true
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "FETCH_DATA") {
fetchData(msg.url)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err.message }));
return true; // literal boolean true
}
});Reusable async handler utility
type AsyncMessageHandler = (
message: any,
sender: chrome.runtime.MessageSender,
) => Promise<any>;
function asyncHandler(fn: AsyncMessageHandler) {
return (
message: any,
sender: chrome.runtime.MessageSender,
sendResponse: (response: any) => void,
): true => {
fn(message, sender)
.then((result) => sendResponse({ __ok: true, result }))
.catch((err) =>
sendResponse({ __ok: false, error: err.message, stack: err.stack }),
);
return true;
};
}
// Usage
chrome.runtime.onMessage.addListener(
asyncHandler(async (msg, sender) => {
if (msg.type === "FETCH") {
const res = await fetch(msg.url);
return await res.json();
}
if (msg.type === "STORAGE_GET") {
return await chrome.storage.local.get(msg.keys);
}
}),
);Client-side unwrapper
async function sendMessage<T>(message: any): Promise<T> {
const response = await chrome.runtime.sendMessage(message);
if (response?.__ok === false) throw new Error(response.error);
return response?.__ok ? response.result : response;
}3. Port-based long-lived connections
For ongoing bidirectional communication: streaming data, real-time updates, progress reporting. Ports keep the service worker alive as long as messages are being sent.
// === Content script: open connection ===
const port = chrome.runtime.connect({ name: "data-stream" });
port.postMessage({ action: "subscribe", topics: ["prices", "news"] });
port.onMessage.addListener((msg) => {
if (msg.type === "update") updateUI(msg.data);
if (msg.type === "error") showError(msg.error);
});
port.onDisconnect.addListener(() => {
if (chrome.runtime.lastError) {
console.error("Port error:", chrome.runtime.lastError.message);
}
// Auto-reconnect with backoff
setTimeout(() => reconnect(), 1000);
});
// === Service worker: handle connections ===
const activePorts = new Map<number, chrome.runtime.Port>();
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== "data-stream") return;
const tabId = port.sender?.tab?.id;
if (tabId) activePorts.set(tabId, port);
port.onMessage.addListener((msg) => {
if (msg.action === "subscribe") {
startStreaming(port, msg.topics);
}
});
port.onDisconnect.addListener(() => {
if (tabId) activePorts.delete(tabId);
cleanupSubscriptions(port);
});
});
// Broadcast to all connected tabs
function broadcast(message: any) {
for (const [tabId, port] of activePorts) {
try {
port.postMessage(message);
} catch {
activePorts.delete(tabId);
}
}
}Port vs sendMessage: when to use which
| Use case | sendMessage | Port |
|---|---|---|
| Single request/response | ✅ | Overkill |
| Multiple messages over time | Inefficient | ✅ |
| Progress reporting | Awkward | ✅ |
| Streaming data | Impossible | ✅ |
| Simple one-off query | ✅ | Overkill |
| Need to detect disconnect | Manual | ✅ Built-in |
4. Service worker → content script
The service worker initiates communication using chrome.tabs.sendMessage:
// Send to a specific tab
async function sendToTab(tabId: number, message: any) {
try {
return await chrome.tabs.sendMessage(tabId, message);
} catch (err) {
// Common: tab doesn't have content script, or script is orphaned
console.warn(`Tab ${tabId} unreachable:`, err);
return null;
}
}
// Send to the active tab
async function sendToActiveTab(message: any) {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) return null;
return sendToTab(tab.id, message);
}
// Send to all tabs matching a URL pattern
async function sendToMatchingTabs(urlPattern: string, message: any) {
const tabs = await chrome.tabs.query({ url: urlPattern });
return Promise.allSettled(
tabs.filter((t) => t.id).map((t) => sendToTab(t.id!, message)),
);
}Content script must have a listener:
// content-script.ts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "HIGHLIGHT") {
const el = document.querySelector(message.selector);
if (el) el.style.outline = "3px solid red";
sendResponse({ found: !!el });
}
return true;
});5. Full three-layer bridge: page ↔ content script ↔ service worker
This pattern enables web page JavaScript (with no chrome.\* API access) to communicate with the extension's service worker through the content script as a relay.
┌─────────────────┐ window.postMessage ┌──────────────────┐ chrome.runtime ┌─────────────────┐
│ Page (main world)│ ◄──────────────────► │ Content Script │ ◄──────────────► │ Service Worker │
│ No chrome.* API │ │ (isolated world) │ │ Full chrome.* API│
│ Full page access │ │ Bridge/relay │ │ No DOM │
└─────────────────┘ └──────────────────┘ └─────────────────┘Page-side client (injected as main world script)
// page-client.ts (world: "MAIN" or injected via web_accessible_resources)
const EXTENSION_CHANNEL = "MY_EXT_BRIDGE";
const pendingRequests = new Map<
string,
{ resolve: Function; reject: Function }
>();
// Send request to extension, get response back
function requestFromExtension(type: string, payload?: any): Promise<any> {
return new Promise((resolve, reject) => {
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
pendingRequests.set(requestId, { resolve, reject });
// Timeout after 30s
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error("Extension request timeout"));
}
}, 30000);
window.postMessage(
{
channel: EXTENSION_CHANNEL,
direction: "TO_EXTENSION",
requestId,
type,
payload,
},
window.location.origin,
); // NEVER use '*'
});
}
// Listen for responses from extension
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.channel !== EXTENSION_CHANNEL) return;
if (event.data?.direction !== "FROM_EXTENSION") return;
const { requestId, response, error } = event.data;
const pending = pendingRequests.get(requestId);
if (!pending) return;
pendingRequests.delete(requestId);
if (error) pending.reject(new Error(error));
else pending.resolve(response);
});
// Usage from page JS:
// const user = await requestFromExtension('GET_USER', { id: '123' });Content script bridge
// bridge.ts (content script, isolated world)
const EXTENSION_CHANNEL = "MY_EXT_BRIDGE";
// Page → Extension
window.addEventListener("message", async (event) => {
if (event.source !== window) return;
if (event.data?.channel !== EXTENSION_CHANNEL) return;
if (event.data?.direction !== "TO_EXTENSION") return;
const { requestId, type, payload } = event.data;
try {
const response = await chrome.runtime.sendMessage({ type, payload });
window.postMessage(
{
channel: EXTENSION_CHANNEL,
direction: "FROM_EXTENSION",
requestId,
response,
},
window.location.origin,
);
} catch (err: any) {
window.postMessage(
{
channel: EXTENSION_CHANNEL,
direction: "FROM_EXTENSION",
requestId,
error: err.message,
},
window.location.origin,
);
}
});
// Extension → Page (forward service worker events to page)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.direction === "TO_PAGE") {
window.postMessage(
{
channel: EXTENSION_CHANNEL,
direction: "FROM_EXTENSION_PUSH",
type: message.type,
payload: message.payload,
},
window.location.origin,
);
sendResponse({ received: true });
}
return true;
});6. Typed message protocol with discriminated unions
Type-safe messaging eliminates string matching bugs:
// === messages.ts (shared types) ===
// Define all messages as a discriminated union
type ContentToBackground =
| { type: "FETCH_API"; url: string; method?: string; body?: string }
| { type: "GET_SETTINGS" }
| { type: "SET_SETTINGS"; settings: Partial<Settings> }
| { type: "LOG_EVENT"; event: string; data?: Record<string, unknown> };
type BackgroundToContent =
| { type: "SETTINGS_CHANGED"; settings: Settings }
| { type: "TOGGLE_UI"; visible: boolean }
| { type: "NOTIFICATION"; title: string; body: string };
// Response types mapped to request types
interface ResponseMap {
FETCH_API: { ok: boolean; status: number; data: string };
GET_SETTINGS: Settings;
SET_SETTINGS: { success: boolean };
LOG_EVENT: void;
SETTINGS_CHANGED: void;
TOGGLE_UI: { wasVisible: boolean };
NOTIFICATION: void;
}
interface Settings {
theme: "light" | "dark";
enabled: boolean;
apiKey: string;
}
// === Type-safe sender ===
async function sendTyped<T extends ContentToBackground>(
message: T,
): Promise<ResponseMap[T["type"]]> {
return chrome.runtime.sendMessage(message);
}
// Usage (fully typed):
const settings = await sendTyped({ type: "GET_SETTINGS" });
// ^ Settings
const result = await sendTyped({
type: "FETCH_API",
url: "https://api.com/data",
});
// ^ { ok: boolean; status: number; data: string }7. Full RPC layer (simulate HTTP through messaging)
For extensions that need HTTP-like semantics (methods, status codes, errors) over chrome.runtime messaging:
// === rpc-types.ts ===
interface RPCMethods {
"user.get": { params: { id: string }; result: User };
"user.update": { params: { id: string; data: Partial<User> }; result: User };
"user.delete": { params: { id: string }; result: void };
"tabs.list": { params: void; result: TabInfo[] };
"settings.get": { params: { key: string }; result: unknown };
"settings.set": { params: { key: string; value: unknown }; result: void };
"fetch.relay": { params: FetchParams; result: FetchResult };
}
interface FetchParams {
url: string;
method?: string;
headers?: Record<string, string>;
body?: string;
}
interface FetchResult {
ok: boolean;
status: number;
statusText: string;
headers: Record<string, string>;
body: string;
}
interface RPCRequest<M extends keyof RPCMethods = keyof RPCMethods> {
__rpc: true;
id: string;
method: M;
params: RPCMethods[M]["params"];
}
interface RPCResponseOk<M extends keyof RPCMethods = keyof RPCMethods> {
__rpc: true;
id: string;
result: RPCMethods[M]["result"];
error?: never;
}
interface RPCResponseError {
__rpc: true;
id: string;
result?: never;
error: { code: number; message: string; data?: unknown };
}
type RPCResponse<M extends keyof RPCMethods = keyof RPCMethods> =
| RPCResponseOk<M>
| RPCResponseError;
// === rpc-client.ts (content script or popup) ===
let rpcCounter = 0;
function createRPC() {
return new Proxy(
{} as {
[M in keyof RPCMethods]: RPCMethods[M]["params"] extends void
? () => Promise<RPCMethods[M]["result"]>
: (params: RPCMethods[M]["params"]) => Promise<RPCMethods[M]["result"]>;
},
{
get(_, method: string) {
return async (params?: unknown) => {
const id = `rpc_${++rpcCounter}_${Date.now()}`;
const response: RPCResponse = await chrome.runtime.sendMessage({
__rpc: true,
id,
method,
params: params ?? null,
});
if (!response?.__rpc) throw new Error("Invalid RPC response");
if (response.error) {
const err = new Error(response.error.message);
(err as any).code = response.error.code;
(err as any).data = response.error.data;
throw err;
}
return response.result;
};
},
},
);
}
const rpc = createRPC();
// Usage: fully typed, looks like function calls
const user = await rpc["user.get"]({ id: "123" });
const tabs = await rpc["tabs.list"]();
await rpc["settings.set"]({ key: "theme", value: "dark" });
// CSP bypass: relay fetch through service worker
const apiResponse = await rpc["fetch.relay"]({
url: "https://api.example.com/data",
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "test" }),
});
// === rpc-server.ts (service worker) ===
type Handler<M extends keyof RPCMethods> = (
params: RPCMethods[M]["params"],
sender: chrome.runtime.MessageSender,
) => Promise<RPCMethods[M]["result"]>;
const handlers = new Map<string, Handler<any>>();
function registerHandler<M extends keyof RPCMethods>(
method: M,
handler: Handler<M>,
) {
handlers.set(method, handler);
}
// Register handlers
registerHandler("user.get", async (params) => {
const res = await fetch(`https://api.example.com/users/${params.id}`);
if (!res.ok) throw { code: res.status, message: res.statusText };
return res.json();
});
registerHandler("tabs.list", async () => {
const tabs = await chrome.tabs.query({});
return tabs.map((t) => ({ id: t.id, title: t.title, url: t.url }));
});
registerHandler("settings.get", async (params) => {
const result = await chrome.storage.sync.get(params.key);
return result[params.key];
});
registerHandler("settings.set", async (params) => {
await chrome.storage.sync.set({ [params.key]: params.value });
});
registerHandler("fetch.relay", async (params, sender) => {
// Security: validate URL against allowlist
const allowed = ["https://api.example.com", "https://cdn.example.com"];
if (!allowed.some((base) => params.url.startsWith(base))) {
throw { code: 403, message: "URL not in allowlist" };
}
const res = await fetch(params.url, {
method: params.method ?? "GET",
headers: params.headers,
body: params.body,
});
const headers: Record<string, string> = {};
res.headers.forEach((v, k) => {
headers[k] = v;
});
return {
ok: res.ok,
status: res.status,
statusText: res.statusText,
headers,
body: await res.text(),
};
});
// Main listener (top level, synchronous registration)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message?.__rpc) return false; // not an RPC message, let other listeners handle
const handler = handlers.get(message.method);
if (!handler) {
sendResponse({
__rpc: true,
id: message.id,
error: { code: -32601, message: `Method not found: ${message.method}` },
});
return true;
}
handler(message.params, sender)
.then((result) => sendResponse({ __rpc: true, id: message.id, result }))
.catch((err) => {
const error =
typeof err === "object" && err.code
? err
: { code: -32000, message: err?.message ?? String(err) };
sendResponse({ __rpc: true, id: message.id, error });
});
return true; // async
});8. Cross-extension messaging
Extensions can communicate with each other via externally_connectable:
// manifest.json of receiving extension
{
"externally_connectable": {
"ids": ["sender_extension_id_here"],
"matches": ["https://your-website.com/*"]
}
}// Sending extension
chrome.runtime.sendMessage(
"target_extension_id",
{ type: "HELLO" },
(response) => {
console.log("Response from other extension:", response);
},
);
// Receiving extension
chrome.runtime.onMessageExternal.addListener(
(message, sender, sendResponse) => {
console.log("From extension:", sender.id);
sendResponse({ ok: true });
},
);9. Common messaging bugs
Bug: "Could not establish connection. Receiving end does not exist."
Causes:
1. No content script injected in the target tab 2. Content script orphaned after extension update 3. Sending to a chrome://, edge://, or other restricted page 4. Service worker terminated and listener not registered synchronously
Fix: always wrap sendMessage/tabs.sendMessage in try/catch.
Bug: sendResponse called but sender never receives
Cause: return true missing or wrapped in a Promise (async function). Fix: use the asyncHandler wrapper from section 2.
Bug: message received by wrong listener
Cause: multiple listeners registered, first one to return true wins. Fix: use message type discrimination. Return false or undefined from listeners that don't handle the message type.
Bug: Port disconnected unexpectedly
Causes:
1. Service worker terminated (no messages sent within 30s) 2. Tab closed or navigated away 3. Extension updated/reloaded
Fix: always handle port.onDisconnect, implement reconnection logic.
Bug: message payload too large
Chrome has an undocumented limit of ~64MB per message. For large data, consider using chrome.storage as a shared buffer or chunking.
Network Requests and CSP Bypass Reference
Table of contents
1. CORS vs CSP in extensions 2. Where fetch() works freely 3. The relay pattern (CSP bypass) 4. declarativeNetRequest (header modification, blocking) 5. Offscreen documents for network + DOM 6. Host permissions and CORS 7. Fetch gotchas in service workers
1. CORS vs CSP in extensions
These are different mechanisms, often confused.
CORS (Cross-Origin Resource Sharing): server-side. Controls which origins can READ responses. A server at api.com decides whether your extension origin can receive data.
- Content scripts follow the page's CORS rules (since Chrome 73)
- Service worker with host_permissions bypasses CORS for declared origins
- Popup/options pages (extension origin) also need host_permissions
CSP (Content Security Policy): page-side. Controls which resources a page can LOAD or CONNECT to. A page with connect-src 'self' blocks ALL fetch() from content scripts to external URLs, even your own API.
- Content scripts are bound by the page's
connect-srcdirective - Service worker is NOT subject to any page's CSP
- Extension pages have their own CSP (configured in manifest)
The practical consequence: a content script on a page with strict CSP cannot fetch your API directly. You MUST relay through the service worker.
2. Where fetch() works freely
| Context | Subject to page CSP? | Subject to CORS? | Needs host_permissions? |
|---|---|---|---|
| Service worker | No | No (with host_permissions) | Yes |
| Popup / options / side panel | No (extension CSP) | No (with host_permissions) | Yes |
| Content script (isolated) | Yes (connect-src) | Yes (page origin rules) | Not sufficient alone |
| Content script (main world) | Yes (fully) | Yes | N/A (no chrome.\* API) |
| Offscreen document | No (extension origin) | No (with host_permissions) | Yes |
3. The relay pattern (CSP bypass)
The standard, officially recommended approach. Content script sends a request to the service worker, which performs the fetch and returns the result.
Basic relay
// === content-script.ts ===
async function relayFetch(
url: string,
init?: RequestInit,
): Promise<{
ok: boolean;
status: number;
headers: Record<string, string>;
body: string;
}> {
// Serialize RequestInit (Headers/body may not be structured-cloneable)
const serialized: Record<string, any> = {};
if (init?.method) serialized.method = init.method;
if (init?.headers) {
serialized.headers =
init.headers instanceof Headers
? Object.fromEntries(init.headers.entries())
: init.headers;
}
if (init?.body) {
serialized.body =
typeof init.body === "string" ? init.body : String(init.body);
}
const response = await chrome.runtime.sendMessage({
type: "__RELAY_FETCH",
url,
init: serialized,
});
if (response?.error) throw new Error(response.error);
return response;
}
// Usage in content script
const result = await relayFetch("https://api.example.com/data", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer token",
},
body: JSON.stringify({ query: "test" }),
});
const data = JSON.parse(result.body);// === background.ts ===
const ALLOWED_ORIGINS = ["https://api.example.com", "https://cdn.example.com"];
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== "__RELAY_FETCH") return false;
// Security: validate sender is a content script in a tab
if (!sender.tab?.id) {
sendResponse({ error: "Unauthorized: not from a tab" });
return true;
}
// Security: validate URL against allowlist
try {
const url = new URL(message.url);
const allowed = ALLOWED_ORIGINS.some((o) => message.url.startsWith(o));
if (!allowed) {
sendResponse({ error: `URL not allowed: ${url.origin}` });
return true;
}
} catch {
sendResponse({ error: "Invalid URL" });
return true;
}
fetch(message.url, message.init || {})
.then(async (res) => {
const headers: Record<string, string> = {};
res.headers.forEach((v, k) => {
headers[k] = v;
});
const body = await res.text();
sendResponse({ ok: res.ok, status: res.status, headers, body });
})
.catch((err) => sendResponse({ error: err.message }));
return true;
});Relay with binary data (ArrayBuffer)
For binary responses (images, files), base64-encode through the relay:
// background.ts handler for binary
if (message.responseType === "arraybuffer") {
fetch(message.url, message.init)
.then((res) => res.arrayBuffer())
.then((buf) => {
const base64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
sendResponse({ ok: true, base64, mimeType: "application/octet-stream" });
})
.catch((err) => sendResponse({ error: err.message }));
return true;
}
// content script: decode
const result = await relayFetch(url, { responseType: "arraybuffer" });
const binary = Uint8Array.from(atob(result.base64), (c) => c.charCodeAt(0));4. declarativeNetRequest
Rule-based network modification. Replaces MV2's webRequest blocking. Requires "declarativeNetRequest" permission.
Static rules (manifest)
{
"declarative_net_request": {
"rule_resources": [
{
"id": "main_rules",
"enabled": true,
"path": "rules.json"
}
]
}
}// rules.json
[
{
"id": 1,
"priority": 1,
"action": { "type": "block" },
"condition": {
"urlFilter": "tracker.example.com",
"resourceTypes": ["script", "image", "xmlhttprequest"]
}
},
{
"id": 2,
"priority": 1,
"action": {
"type": "redirect",
"redirect": { "extensionPath": "/blocked.html" }
},
"condition": {
"urlFilter": "malware.example.com",
"resourceTypes": ["main_frame"]
}
},
{
"id": 3,
"priority": 2,
"action": {
"type": "modifyHeaders",
"requestHeaders": [{ "header": "cookie", "operation": "remove" }],
"responseHeaders": [
{ "header": "set-cookie", "operation": "remove" },
{ "header": "x-frame-options", "operation": "remove" },
{ "header": "content-security-policy", "operation": "remove" }
]
},
"condition": {
"urlFilter": "*",
"initiatorDomains": ["target-site.com"],
"resourceTypes": ["main_frame", "sub_frame"]
}
}
]Dynamic rules (runtime)
// Add/update rules at runtime
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1000], // remove old rule if exists
addRules: [
{
id: 1000,
priority: 1,
action: {
type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS,
responseHeaders: [
{
header: "content-security-policy",
operation: chrome.declarativeNetRequest.HeaderOperation.REMOVE,
},
],
},
condition: {
initiatorDomains: ["example.com"],
resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME],
},
},
],
});
// List current dynamic rules
const rules = await chrome.declarativeNetRequest.getDynamicRules();
// Session rules (cleared on browser restart)
await chrome.declarativeNetRequest.updateSessionRules({
addRules: [
/* same format */
],
});Rule limits
| Rule type | Max count |
|---|---|
| Static rules (per ruleset) | 330,000 |
| Enabled static rulesets | 50 |
| Dynamic rules | 30,000 |
| Session rules | 5,000 |
| Regex rules (across all) | 2,000 |
Important: CSP header removal caveats
appendoperation on CSP headers does NOT relax the policy (CSP spec says additional headers can only be more restrictive)removeis the only way to relax CSP via declarativeNetRequest- This removes the ENTIRE CSP header, not individual directives
- Use
initiatorDomainsorurlFilterto scope narrowly
5. Offscreen documents for network + DOM
When you need both fetch() and DOM APIs (DOMParser, Canvas, Audio). See service-worker.md for full offscreen document setup.
Key facts:
- Runs in extension origin (not subject to page CSP)
- Has fetch() and full DOM
- Only chrome.runtime API available
- One offscreen document per extension
- Must specify a Reason enum value
- Created on demand, can be closed when done
6. Host permissions and CORS
The service worker needs host_permissions to fetch cross-origin URLs without CORS errors:
{
"host_permissions": ["https://api.example.com/*", "https://cdn.example.com/*"]
}With matching host_permissions, the service worker's fetch() ignores CORS entirely. Without them, normal CORS rules apply (the server must send appropriate headers).
Prefer optional_host_permissions for URLs not needed at install time:
{
"optional_host_permissions": ["https://*/*"]
}Then request at runtime (must be in a user gesture handler):
const granted = await chrome.permissions.request({
origins: ["https://new-api.example.com/*"],
});7. Fetch gotchas in service workers
No XMLHttpRequest
Service workers only support fetch(). No XMLHttpRequest, no $.ajax.
Request body types
Service workers support: string, Blob, ArrayBuffer, FormData, URLSearchParams. NOT supported: ReadableStream as body (in some Chrome versions).
Streaming responses
// Streaming is supported in SW fetch
const response = await fetch(url);
const reader = response.body!.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Process chunk. BUT: can't stream to content script via sendMessage.
// Use Ports for streaming to content scripts.
}Fetch and SW termination
If the SW terminates during a pending fetch, the fetch is cancelled. For long-running downloads, use a keep-alive pattern or delegate to an offscreen document.
Permissions Reference
Table of contents
1. Permission types 2. All available permissions and their warnings 3. activeTab strategy 4. Optional permissions (runtime request) 5. Host permissions 6. Permission removal 7. CWS review impact
1. Permission types
| Manifest key | When granted | User prompt | CWS impact |
|---|---|---|---|
permissions | At install | Install dialog | Always reviewed |
optional_permissions | At runtime | Prompt dialog | Less scrutiny |
host_permissions | At install | Install dialog | Triggers manual review if broad |
optional_host_permissions | At runtime | Prompt dialog | Minimal impact |
2. All available permissions and their warnings
No warning (safe to use freely)
activeTab, alarms, contextMenus, declarativeNetRequestFeedback, dns, enterprise.deviceAttributes, enterprise.hardwarePlatform, enterprise.networkingAttributes, enterprise.platformKeys, favicon, fileBrowserHandler, fontSettings, gcm, idle, loginState, offscreen, power, printing, printingMetrics, runtime, scripting, search, sidePanel, storage, system.cpu, system.display, system.memory, system.storage, ttsEngine, unlimitedStorage, webAuthenticationProxy
Warning-generating permissions
| Permission | Warning message |
|---|---|
bookmarks | Read and change your bookmarks |
clipboardRead | Read data you copy and paste |
clipboardWrite | Modify data you copy and paste |
contentSettings | Change settings that control websites' access... |
cookies | (combined with host permissions warning) |
debugger | Access the page debugger backend |
declarativeNetRequest | Block page content |
desktopCapture | Capture content of your screen |
downloads | Manage your downloads |
geolocation | Detect your physical location |
history | Read and change your browsing history |
identity | Know your email address |
management | Manage your apps, extensions, and themes |
nativeMessaging | Communicate with cooperating native applications |
notifications | Display notifications |
pageCapture | Read and change all your data on all websites |
privacy | Change privacy-related settings |
proxy | Read and modify proxy settings |
tabCapture | Read and change all your data on all websites |
tabs | Read your browsing activity (URL, title, favicon) |
topSites | Read a list of your most frequently visited sites |
tts | N/A (generally no warning) |
webNavigation | Read your browsing activity |
webRequest | (varies, usually combined with host permissions) |
Host permission warnings
| Pattern | Warning |
|---|---|
<all_urls> | Read and change all your data on all websites |
https://*/* | Read and change all your data on all websites |
https://*.google.com/* | Read and change your data on all google.com sites |
https://example.com/* | Read and change your data on example.com |
3. activeTab strategy
activeTab is the most important permission for CWS-friendly extensions. It grants temporary access to the current tab ONLY when the user explicitly invokes the extension:
Triggers that grant activeTab:
- Clicking the extension icon (action)
- Using a keyboard shortcut (command)
- Selecting a context menu item
- Accepting a suggestion from the omnibox
What it grants (temporarily):
chrome.scripting.executeScripton that tabchrome.scripting.insertCSSon that tab- Access to
tab.url,tab.title,tab.favIconUrlfor that tab - Host permission for that tab's origin
What it does NOT grant:
- Persistent access (revoked on navigation or tab close)
- Access to other tabs
- Background injection without user gesture
No install warning. This is the key advantage.
// Minimal, warning-free permission set
{
"permissions": ["activeTab", "scripting", "storage"]
}4. Optional permissions (runtime request)
Request permissions only when the user needs a specific feature:
// Check if already granted
const hasPermission = await chrome.permissions.contains({
permissions: ["bookmarks"],
origins: ["https://api.example.com/*"],
});
if (hasPermission) {
useFeature();
return;
}
// Request (MUST be in a click/gesture handler)
document.getElementById("enable-btn")!.addEventListener("click", async () => {
const granted = await chrome.permissions.request({
permissions: ["bookmarks"],
origins: ["https://api.example.com/*"],
});
if (granted) {
useFeature();
} else {
showExplanation();
}
});Best practices for runtime permission requests
1. Explain before asking: show why the permission is needed before calling request() 2. Request just-in-time: ask when the user activates the feature, not at startup 3. Handle denial gracefully: the feature should degrade, not crash 4. Don't ask repeatedly: if denied, show a manual enable path 5. Combine permissions: batch related permissions in one request dialog
Permissions you CANNOT request at runtime
Some permissions can only be declared in manifest permissions: debugger, declarativeNetRequest, devtools, experimental, geolocation, mdns, proxy, tts, ttsEngine, wallpaper.
5. Host permissions deep dive
Host permissions control which origins the extension can interact with:
fetch()from service worker/extension pages to that origin (bypasses CORS)chrome.scripting.executeScript()on pages from that originchrome.tabs.sendMessage()to content scripts on that origin- Access to
tab.urlfor tabs on that origin (withouttabspermission)
Narrowing host permissions
// ❌ BAD: triggers scary warning + manual CWS review
"host_permissions": ["<all_urls>"]
// ✅ BETTER: specific domains
"host_permissions": ["https://api.myservice.com/*"]
// ✅ BEST: optional, requested at runtime
"optional_host_permissions": ["https://*/*"]Host permission with activeTab
With activeTab, you don't need host_permissions for the current tab when the user invokes the extension. You only need host_permissions for:
- Background fetches to your API
- Programmatic tab injection without user gesture
- Cross-origin requests from the service worker
6. Permission removal
Remove permissions you no longer need:
await chrome.permissions.remove({
permissions: ["bookmarks"],
origins: ["https://old-api.example.com/*"],
});Benefits:
- Reduces attack surface
- Improves user trust
- Reduces CWS review scope on next update
7. CWS review impact
| Permissions | Review type | Typical time |
|---|---|---|
activeTab + storage + scripting | Automated | Hours to 1 day |
| + specific host_permissions (1-2 domains) | Automated | 1-3 days |
+ tabs or webNavigation | May trigger manual | 3-7 days |
+ <all_urls> or https://*/* | Manual review | 1-4 weeks |
+ declarativeNetRequest + broad hosts | Manual review | 1-4 weeks |
+ debugger or nativeMessaging | Deep manual review | 2-6 weeks |
Every permission you declare must be used. Unused permissions cause CWS rejection. Justify each permission in your submission and privacy policy.
Chrome Web Store Publishing Reference
Table of contents
1. Submission checklist 2. Required assets 3. Privacy policy requirements 4. Common rejection reasons and fixes 5. Update process 6. Enterprise distribution (alternative)
1. Submission checklist
Before submitting to the Chrome Web Store:
- [ ]
manifest.jsonhasname,version,description, correctmanifest_version: 3 - [ ] Icons: 16, 48, 128px PNGs included and referenced in manifest
- [ ] Every declared permission is actually used in code
- [ ] No unused permissions (causes rejection)
- [ ] No
eval(),new Function(),document.write()with dynamic strings - [ ] No remote code loading (all JS bundled locally)
- [ ] Code is not obfuscated (minified is OK)
- [ ] Privacy policy URL ready (if you collect ANY data)
- [ ] Store listing screenshots (1280x800 or 640x400)
- [ ] Store description (up to 132 chars for short, detailed for long)
- [ ] Category selected
- [ ] Single purpose clearly defined
- [ ] Tested in Chrome stable (not just Canary/Dev)
- [ ]
host_permissionsare as narrow as possible
2. Required assets
Store listing images
| Asset | Size | Required |
|---|---|---|
| Icon | 128x128 PNG | Yes |
| Screenshots | 1280x800 or 640x400 | Yes (1-5) |
| Small promo tile | 440x280 | No but recommended |
| Large promo tile | 920x680 | No |
| Marquee promo tile | 1400x560 | No |
Store listing text
- Name: max 75 chars (45 recommended for display)
- Short description: max 132 chars (shows in search results)
- Detailed description: no hard limit, supports basic formatting
- Category: choose the most specific match
- Language: primary language + translations
3. Privacy policy requirements
Required if your extension:
- Collects, transmits, or stores user data
- Uses any personal or sensitive data
- Has
host_permissionsortabspermission - Uses
cookies,history,bookmarks,identity
The privacy policy must disclose:
- What data is collected
- How data is used
- Whether data is shared with third parties
- How users can request data deletion
- Data retention period
Host it on a publicly accessible URL (your website, GitHub page, Notion page).
Even for extensions that don't collect data, consider adding a simple policy: "This extension does not collect, store, or transmit any user data."
4. Common rejection reasons and fixes
Excessive permissions (~36% of rejections)
Problem: requesting permissions you don't use or requesting broad permissions when narrow ones suffice.
Fix:
- Remove every permission not actively used in code
- Replace
<all_urls>with specific domains - Replace
tabswithactiveTabif you only need the current tab - Move non-essential permissions to
optional_permissions - Add justification in the "Permission justification" field
Missing or inadequate privacy policy (~29%)
Problem: no privacy policy, or policy doesn't match actual data practices.
Fix: create a privacy policy page that specifically addresses your extension's data handling. Update it when you add features.
Single purpose violation
Problem: extension does too many unrelated things.
Fix: each extension should have ONE clear purpose. If you have multiple features, they should all serve the same core purpose. Example: "Tab manager" is OK. "Tab manager
- ad blocker + screenshot tool" is not.
Remote code execution
Problem: loading JS from external servers, using eval(), or using document.write() with dynamic content.
Fix: bundle ALL code locally. If you need dynamic behavior, use chrome.storage for configuration, not remote scripts. For template engines, use sandbox pages.
Obfuscated code
Problem: code is intentionally made unreadable (not just minified).
Fix: submit readable or minified (not obfuscated) code. Reviewers must be able to understand what your code does. Source maps are not a substitute.
Deceptive functionality
Problem: extension does something different than described, or has hidden features.
Fix: store listing must accurately describe ALL extension behavior. No hidden data collection, no undisclosed network requests.
Keyword spam in listing
Problem: stuffing description with competitor names or unrelated keywords.
Fix: describe your extension's actual features. Don't mention competitors by name in the description.
5. Update process
Publishing an update
1. Increment version in manifest.json (must be higher than current CWS version) 2. Create a new zip of the extension 3. Upload to CWS dashboard (same listing) 4. Fill in changelog (optional but recommended) 5. Submit for review
Auto-update timeline
- Chrome checks for updates every few hours
- Users receive updates automatically (no action required)
- The old version continues running until Chrome restarts or the update is applied
- Content scripts from the old version become orphaned (see content-scripts.md)
Breaking changes in updates
When updating, handle data migration in chrome.runtime.onInstalled:
chrome.runtime.onInstalled.addListener(async ({ reason, previousVersion }) => {
if (reason === "update") {
// Migrate data, re-inject content scripts, etc.
}
});Staged rollout
CWS supports percentage-based rollout (5%, 10%, 50%, 100%). Use this for risky updates to catch issues early.
6. Enterprise distribution
For internal/corporate extensions not on the CWS:
Self-hosted
Host a .crx file and an update_url XML file:
// manifest.json
{ "update_url": "https://yourserver.com/updates.xml" }<!-- updates.xml -->
<?xml version='1.0' encoding='UTF-8'?>
<gupdate xmlns='http://www.google.com/update2/response' protocol='2.0'>
<app appid='YOUR_EXTENSION_ID'>
<updatecheck crid='YOUR_EXTENSION_ID' version='1.0.0'
prodversionmin='116'
hash_sha256='...'
url='https://yourserver.com/extension.crx'/>
</app>
</gupdate>Requires enterprise policy to allowlist the extension ID.
Developer mode (testing only)
Load unpacked at chrome://extensions with Developer Mode enabled. Not suitable for distribution. Extension ID changes between machines.
Service Worker Reference
Table of contents
1. Lifecycle and termination rules 2. Event listener registration rules 3. State management patterns 4. Alarms (replacing setTimeout/setInterval) 5. Startup and install events 6. Keep-alive patterns (when absolutely needed) 7. Identity and OAuth 8. Offscreen documents
1. Lifecycle and termination rules
The service worker is Chrome's replacement for MV2's persistent background page. It is event-driven and ephemeral.
Termination timeline
| Condition | Timeout |
|---|---|
| No pending events or API calls | 30 seconds |
| Single long-running task | 5 minutes hard cap |
| Active fetch() request | 30 seconds from last network activity |
| Active chrome.\* API call | Resets the 30s idle timer |
| DevTools open on SW | Never terminates (masks bugs!) |
What resets the 30s idle timer (Chrome 110+)
Any chrome._ API call or event: onMessage, onAlarm, onClicked, tabs.onUpdated, webNavigation._, etc. Also: active fetch(), WebSocket messages (Chrome 116+), native messaging host communication (Chrome 105+).
What does NOT keep it alive
setTimeout/setInterval- these are cancelled on termination- Global variables - reset to
undefinedon restart - Promises that haven't resolved - lost
localStorage/sessionStorage- do not exist in service workers
What's lost on termination
// ALL of these vanish:
let cache = {}; // gone
let counter = 0; // gone
const ws = new WebSocket(url); // disconnected
setTimeout(fn, 60000); // cancelled
const pending = fetch(url); // if SW terminates before response, lost2. Event listener registration rules
CRITICAL: All event listeners MUST be registered synchronously at the top level of the service worker file. Chrome records which events a SW listens to. If a listener isn't registered synchronously on startup, Chrome won't wake the SW for that event.
// ✅ CORRECT: top-level, synchronous
chrome.runtime.onMessage.addListener(handleMessage);
chrome.tabs.onUpdated.addListener(handleTabUpdate);
chrome.alarms.onAlarm.addListener(handleAlarm);
chrome.runtime.onInstalled.addListener(handleInstall);
chrome.action.onClicked.addListener(handleClick);
// ✅ CORRECT: conditional logic inside the handler is fine
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "A") handleA(msg, sendResponse);
if (msg.type === "B") handleB(msg, sendResponse);
return true;
});
// ❌ BROKEN: listener inside async/callback
chrome.storage.local.get("config", (config) => {
if (config.enableFeature) {
chrome.tabs.onUpdated.addListener(handleTabUpdate); // LOST after restart
}
});
// ❌ BROKEN: listener inside import().then()
import("./handlers.js").then((module) => {
chrome.runtime.onMessage.addListener(module.handler); // LOST after restart
});
// ✅ WORKAROUND for dynamic imports: register first, delegate later
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
import("./handlers.js").then((m) => m.handler(msg, sender, sendResponse));
return true;
});With "type": "module" in manifest, static import statements are fine:
// background.js with "type": "module"
import { handleMessage } from "./handlers.js"; // ✅ static import is OK
chrome.runtime.onMessage.addListener(handleMessage);3. State management patterns
Use chrome.storage.session for ephemeral state
chrome.storage.session persists across SW restarts but clears on browser close. Perfect for auth tokens, computed caches, in-progress operations.
// Initialize access for content scripts (call once at top level)
chrome.storage.session.setAccessLevel({
accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS",
});
// State helpers
async function getState<T>(key: string, fallback: T): Promise<T> {
const result = await chrome.storage.session.get(key);
return (result[key] as T) ?? fallback;
}
async function setState(key: string, value: unknown): Promise<void> {
await chrome.storage.session.set({ [key]: value });
}
async function updateState<T>(
key: string,
fallback: T,
updater: (prev: T) => T,
): Promise<T> {
const current = await getState(key, fallback);
const next = updater(current);
await setState(key, next);
return next;
}
// Usage
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "INCREMENT") {
updateState("counter", 0, (n) => n + 1).then(sendResponse);
return true;
}
});Use chrome.storage.local for persistent state
// Batch reads for performance
const { token, settings, cache } = await chrome.storage.local.get([
"token",
"settings",
"cache",
]);
// Atomic batch writes
await chrome.storage.local.set({
token: newToken,
settings: { ...settings, theme: "dark" },
lastUpdated: Date.now(),
});4. Alarms: replacing setTimeout/setInterval
chrome.alarms survives SW termination. Requires "alarms" permission. Minimum interval: 30 seconds (Chrome 120+, was 1 minute before).
// Create alarms
chrome.alarms.create("periodic-sync", { periodInMinutes: 1 }); // recurring
chrome.alarms.create("delayed-task", { delayInMinutes: 0.5 }); // one-shot (30s)
chrome.alarms.create("daily-check", { periodInMinutes: 1440 }); // daily
chrome.alarms.create("specific-time", { when: Date.now() + 60000 }); // absolute time
// Handle alarms (TOP LEVEL)
chrome.runtime.onMessage.addListener(handleMessage); // other listeners...
chrome.alarms.onAlarm.addListener(async (alarm) => {
switch (alarm.name) {
case "periodic-sync":
await syncData();
break;
case "delayed-task":
await processQueue();
break;
}
});
// Idempotent alarm creation (don't duplicate on every SW wake)
async function ensureAlarms() {
const existing = await chrome.alarms.getAll();
const names = existing.map((a) => a.name);
if (!names.includes("periodic-sync")) {
chrome.alarms.create("periodic-sync", { periodInMinutes: 1 });
}
}
ensureAlarms();5. Startup and install events
// Runs on install, update, and Chrome update
chrome.runtime.onInstalled.addListener((details) => {
switch (details.reason) {
case "install":
// First install: set defaults, open onboarding tab
chrome.storage.sync.set({ theme: "light", enabled: true });
chrome.tabs.create({ url: "onboarding.html" });
break;
case "update":
// Extension updated: migrate data, re-inject content scripts
const prev = details.previousVersion;
console.log(
`Updated from ${prev} to ${chrome.runtime.getManifest().version}`,
);
reInjectContentScripts();
break;
case "chrome_update":
// Chrome browser itself updated
break;
}
});
// Runs every time the SW starts (install, update, wake from idle, browser start)
chrome.runtime.onStartup.addListener(() => {
// Good place to ensure alarms exist, warm caches, etc.
ensureAlarms();
});6. Keep-alive patterns (use sparingly)
Sometimes you need the SW alive for longer operations (e.g., streaming responses). These patterns extend lifetime but should be last resorts.
Periodic chrome.\* API call
// Keep alive for up to 5 minutes during a long operation
let keepAliveInterval: ReturnType<typeof setInterval> | null = null;
function startKeepAlive() {
keepAliveInterval = setInterval(() => {
chrome.runtime.getPlatformInfo(() => {}); // resets 30s timer
}, 25000);
}
function stopKeepAlive() {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
keepAliveInterval = null;
}
}
// Usage during long operation
async function longRunningTask() {
startKeepAlive();
try {
// ... work that takes > 30s ...
} finally {
stopKeepAlive();
}
}Port-based keep-alive (offscreen document keeps SW alive)
An open port from an offscreen document keeps the SW alive indefinitely. This is the nuclear option and should only be used for genuinely persistent requirements (e.g., real-time WebSocket proxy). Even then, implement cleanup.
7. Identity and OAuth
Google account (simplest)
// manifest.json: "permissions": ["identity"]
// Also needs "oauth2": { "client_id": "...", "scopes": ["..."] }
const token = await chrome.identity.getAuthToken({ interactive: true });
const response = await fetch("https://www.googleapis.com/userinfo/v2/me", {
headers: { Authorization: `Bearer ${token.token}` },
});Generic OAuth (non-Google)
const redirectUrl = chrome.identity.getRedirectURL(); // https://<ext-id>.chromiumapp.org/
const authUrl = new URL("https://provider.com/oauth/authorize");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", redirectUrl);
authUrl.searchParams.set("response_type", "token");
authUrl.searchParams.set("scope", "read write");
const responseUrl = await chrome.identity.launchWebAuthFlow({
url: authUrl.toString(),
interactive: true,
});
const url = new URL(responseUrl);
const token = url.hash
.split("&")
.find((p) => p.startsWith("access_token="))
?.split("=")[1];
await chrome.storage.session.set({ accessToken: token });8. Offscreen documents
When you need DOM APIs unavailable in service workers (DOMParser, Canvas, Audio, Clipboard):
// background.ts
async function ensureOffscreen() {
const contexts = await chrome.runtime.getContexts({
contextTypes: ["OFFSCREEN_DOCUMENT"],
documentUrls: [chrome.runtime.getURL("offscreen.html")],
});
if (contexts.length > 0) return;
await chrome.offscreen.createDocument({
url: "offscreen.html",
reasons: [chrome.offscreen.Reason.DOM_PARSER],
justification: "Parse HTML responses",
});
}
// Delegate work to offscreen doc
async function parseHTML(html: string) {
await ensureOffscreen();
return chrome.runtime.sendMessage({
target: "offscreen",
type: "PARSE",
html,
});
}<!-- offscreen.html -->
<script src="offscreen.js"></script>// offscreen.js
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.target !== "offscreen") return;
if (msg.type === "PARSE") {
const doc = new DOMParser().parseFromString(msg.html, "text/html");
const title = doc.querySelector("title")?.textContent;
const links = [...doc.querySelectorAll("a")].map((a) => a.href);
sendResponse({ title, links });
return true;
}
});Limitations: only one offscreen document per extension, only chrome.runtime API available, must specify a Reason enum value. Available reasons: TESTING, AUDIO_PLAYBACK, IFRAME_SCRIPTING, DOM_SCRAPING, DOM_PARSER, BLOBS, CLIPBOARD, LOCAL_STORAGE, WORKERS, BATTERY_STATUS, MATCH_MEDIA, GEOLOCATION, USER_MEDIA, DISPLAY_MEDIA.
Web Accessible Resources Reference
What are web accessible resources?
Extension files (scripts, images, CSS, HTML) that web pages can access. By default, extension resources are NOT accessible from web pages. You must explicitly declare them.
MV3 declaration (scoped by origin)
{
"web_accessible_resources": [
{
"resources": ["inject.js", "styles/*.css", "images/logo.png"],
"matches": ["https://example.com/*"]
},
{
"resources": ["shared-widget.js"],
"matches": ["<all_urls>"],
"use_dynamic_url": true
},
{
"resources": ["inter-ext-page.html"],
"extension_ids": ["other_extension_id"]
}
]
}Fields
resources: glob patterns for files to exposematches: URL patterns of pages that can access these resourcesextension_ids: other extensions that can access (for cross-extension resources)use_dynamic_url: iftrue, URL changes each session (prevents fingerprinting)
Accessing from web pages
// From content script or main world script
const url = chrome.runtime.getURL("images/logo.png");
// Returns: chrome-extension://abcdef123456/images/logo.png
// Or with dynamic URL: chrome-extension://abcdef123456/dynamic-hash/images/logo.pngCommon use cases
Injecting a script into the page's main world
// content-script.ts
const script = document.createElement("script");
script.src = chrome.runtime.getURL("inject.js");
script.onload = () => script.remove(); // clean up
(document.head || document.documentElement).appendChild(script);Requires inject.js in web_accessible_resources.
Loading extension CSS into a page
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = chrome.runtime.getURL("styles/content.css");
document.head.appendChild(link);Loading images in content script UI
const img = document.createElement("img");
img.src = chrome.runtime.getURL("images/icon.png");Embedding extension HTML in an iframe
const iframe = document.createElement("iframe");
iframe.src = chrome.runtime.getURL("widget.html");
iframe.style.cssText =
"width:400px;height:300px;border:none;position:fixed;z-index:999999;";
document.body.appendChild(iframe);The iframe runs in the extension's origin and has access to chrome.\* APIs.
Security implications
Fingerprinting risk: any page matching the matches pattern can probe for your extension by trying to load a web_accessible_resource. Use use_dynamic_url: true to mitigate (URL changes each browser session).
Scope narrowly: only expose files that MUST be page-accessible. Never expose your entire extension directory.
No sensitive data: never put API keys, tokens, or private logic in web-accessible files. They're readable by any matching page.
Content script injection vs web_accessible_resources: for script injection into the page, prefer chrome.scripting.executeScript({ world: 'MAIN', func: ... }) over loading a web_accessible_resource via <script> tag. The former doesn't require the file to be web-accessible and is harder to fingerprint.
Related skills
How it compares
Choose chrome-extension over generic JavaScript skills when MV3-specific APIs, CSP constraints, and store packaging are in scope.
FAQ
What is chrome-extension?
Comprehensive guide for building Chrome extensions with Manifest V3. Use this skill whenever the user mentions Chrome extension, browser extension, manifest.json, content script, s
When should I use chrome-extension?
Comprehensive guide for building Chrome extensions with Manifest V3. Use this skill whenever the user mentions Chrome extension, browser extension, manifest.json, content script, s
Is chrome-extension safe to install?
Review the Security Audits panel on this page before production use.