ion for cross-context communication. V3 requires explicit Promise resolution to keep the message port alive during asynchronous operations.
// MessageRouter.ts
import { ExtensionMessage, MessageResponse } from './types';
export class MessageRouter {
private handlers: Map<string, (msg: ExtensionMessage) => Promise<MessageResponse>> = new Map();
register(action: string, handler: (msg: ExtensionMessage) => Promise<MessageResponse>): void {
this.handlers.set(action, handler);
}
initialize(): void {
chrome.runtime.onMessage.addListener((request: ExtensionMessage, _sender, _sendResponse) => {
const handler = this.handlers.get(request.type);
if (!handler) {
return Promise.reject(new Error(`Unregistered message type: ${request.type}`));
}
// Returning the Promise keeps the message channel open until resolution
return handler(request);
});
}
}
Architecture Rationale: Centralizing message routing eliminates scattered addListener calls and enforces a consistent async contract. Returning the Promise directly satisfies the V3 runtime requirement without relying on deprecated callback patterns.
2. Externalize State with Deterministic Storage
Service worker termination destroys all JavaScript heap memory. Relying on module-level variables or class instances for caching guarantees data loss. The solution is to treat chrome.storage as the single source of truth.
// StateStore.ts
export class StateStore {
private static readonly CACHE_KEY = 'extension_runtime_state';
static async persist(payload: Record<string, unknown>): Promise<void> {
await chrome.storage.local.set({ [this.CACHE_KEY]: payload });
}
static async retrieve<T>(): Promise<T | null> {
const result = await chrome.storage.local.get(this.CACHE_KEY);
return (result[this.CACHE_KEY] as T) ?? null;
}
static async clear(): Promise<void> {
await chrome.storage.local.remove(this.CACHE_KEY);
}
}
Architecture Rationale: Abstracting storage operations behind a static utility ensures consistent serialization/deserialization. Using chrome.storage.local provides automatic persistence across worker restarts, browser sessions, and extension updates.
3. Implement Deterministic Background Scheduling
Long-running operations cannot execute continuously. The chrome.alarms API provides a reliable mechanism for periodic execution that survives worker termination.
// BackgroundScheduler.ts
export class BackgroundScheduler {
static readonly SYNC_ALARM = 'data_sync_cycle';
static readonly INTERVAL_MINUTES = 15;
static initialize(): void {
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === this.SYNC_ALARM) {
await this.executeSyncCycle();
}
});
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create(this.SYNC_ALARM, {
periodInMinutes: this.INTERVAL_MINUTES,
});
});
}
private static async executeSyncCycle(): Promise<void> {
const pendingQueue = await StateStore.retrieve<string[]>('sync_queue') ?? [];
if (pendingQueue.length === 0) return;
for (const endpoint of pendingQueue) {
await fetch(endpoint, { method: 'POST' });
}
await StateStore.persist({ sync_queue: [] });
}
}
Architecture Rationale: Decoupling scheduling from execution logic ensures the worker only wakes when necessary. The alarm listener acts as a deterministic trigger, while the actual work is batched and state-driven.
4. Configure Context-Aware Content Injection
V3 separates content script execution into two distinct contexts. Choosing the correct world prevents API access failures and page script collisions.
// ContentInjector.ts
export class ContentInjector {
static async injectIntoMainWorld(tabId: number, scriptPath: string): Promise<void> {
await chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
files: [scriptPath],
});
}
static async injectIntoIsolatedWorld(tabId: number, scriptPath: string): Promise<void> {
await chrome.scripting.executeScript({
target: { tabId },
world: 'ISOLATED',
files: [scriptPath],
});
}
}
Architecture Rationale: Explicit injection methods prevent accidental context leakage. MAIN world access is reserved for DOM manipulation and page variable interaction. ISOLATED world is used when chrome.* APIs are required without exposing extension internals to the host page.
Pitfall Guide
Migration failures rarely stem from syntax errors. They originate from architectural assumptions that no longer align with the V3 runtime. The following pitfalls represent the most frequent production blockers, along with proven remediation strategies.
1. The Synchronous Callback Fallacy
Explanation: Developers continue using the third sendResponse parameter in chrome.runtime.onMessage, assuming it will wait for async operations. In V3, the callback executes immediately, returning undefined before network requests or storage reads complete.
Fix: Always return a Promise from the listener. The runtime automatically keeps the message port open until the Promise resolves or rejects. Remove all sendResponse references.
2. Volatile Global State Assumption
Explanation: Storing configuration, user preferences, or cached API responses in module-level variables or singleton classes. When the service worker terminates, the JavaScript heap is garbage-collected, and all references are lost.
Fix: Treat chrome.storage.local as the primary data layer. Load state during worker initialization (chrome.runtime.onStartup or onInstalled) and persist changes immediately after mutations.
3. Blanket Host Permission Reliance
Explanation: Declaring <all_urls> or broad wildcard patterns in the manifest. V3 rejects these during review and enforces strict origin matching. Extensions requesting unnecessary permissions face higher user friction and store rejection rates.
Fix: Declare only the exact origins required for API calls or content injection. Use chrome.permissions.request() at runtime with a clear UX explanation. Implement graceful degradation when permissions are denied.
4. Inline Execution & Eval Blocking
Explanation: Embedding <script> tags directly in popup HTML or extension pages, or using eval()/new Function() for dynamic logic. V3's Content Security Policy permanently blocks inline execution and dynamic code generation.
Fix: Move all logic to external .js or .ts files. Use static imports or chrome.scripting.executeScript for dynamic injection. Replace dynamic code generation with configuration-driven execution patterns.
5. Unthrottled Background Operations
Explanation: Running continuous polling loops, WebSocket listeners, or unbounded setTimeout chains in the service worker. The runtime terminates the worker after ~30 seconds of inactivity, killing any ongoing operation.
Fix: Replace continuous loops with chrome.alarms for periodic tasks. Use chrome.webRequest or chrome.tabs events for event-driven triggers. For real-time communication, delegate WebSocket handling to a content script or offscreen document.
6. Context World Collision
Explanation: Injecting scripts into the MAIN world while expecting chrome.* API access, or injecting into ISOLATED while attempting to read page-level JavaScript variables. The two worlds operate in separate execution contexts with strict boundary enforcement.
Fix: Audit every executeScript call. Use MAIN exclusively for DOM manipulation and page variable interaction. Use ISOLATED when extension APIs are required. Communicate between worlds using window.postMessage with strict origin validation.
7. Silent Permission Request Failures
Explanation: Calling chrome.permissions.request() without handling the rejection callback or providing user feedback. When users deny the prompt, the extension continues executing with missing capabilities, causing cryptic runtime errors.
Fix: Always implement a rejection handler. Disable dependent features gracefully, display a non-intrusive notification, and provide a clear path to re-request permissions. Never assume the prompt will succeed.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Periodic data synchronization | chrome.alarms with 15-minute intervals | Survives worker termination; respects runtime limits | Low CPU/battery overhead; predictable network load |
| Real-time WebSocket communication | Offscreen Document or Content Script | Service workers cannot maintain persistent connections | Moderate complexity; requires message routing |
| Page variable manipulation | MAIN world injection | Direct access to host page scope | High security risk if not sandboxed; requires strict origin validation |
| Extension API access in content | ISOLATED world injection | Full chrome.* access without page pollution | Low risk; standard V3 pattern |
| Runtime permission escalation | chrome.permissions.request() with UX flow | Complies with V3 least-privilege model | Increases user friction; requires clear value proposition |
| Long-running data processing | Batch processing via chrome.alarms | Prevents worker timeout termination | Higher latency; improved stability |
Configuration Template
{
"manifest_version": 3,
"name": "Production Extension Template",
"version": "1.0.0",
"description": "V3-compliant extension architecture",
"permissions": [
"storage",
"alarms",
"scripting",
"tabs"
],
"host_permissions": [
"https://api.yourdomain.com/*",
"https://app.yourdomain.com/*"
],
"background": {
"service_worker": "dist/background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://app.yourdomain.com/*"],
"js": ["dist/content-isolated.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
Quick Start Guide
- Initialize the Project Structure: Create separate entry points for
background.ts, content-isolated.ts, and popup.ts. Configure your bundler (Vite, Webpack, or esbuild) to output to a dist/ directory with explicit chunk separation.
- Configure the Manifest: Copy the template above and replace placeholder domains with your actual API and app origins. Ensure
host_permissions matches only the endpoints your extension actively communicates with.
- Wire the Service Worker: Import your
MessageRouter, StateStore, and BackgroundScheduler into background.ts. Call their .initialize() methods at the top level. Export nothing; the runtime loads the module automatically.
- Validate CSP Compliance: Remove all inline scripts from
popup.html. Reference external JS files using standard <script src="..."> tags. Run a local build and verify no eval() or dynamic script generation exists in the output bundle.
- Test Lifecycle Behavior: Load the unpacked extension via
chrome://extensions. Open the service worker DevTools console. Manually click "Inspect views: Service Worker" to verify initialization. Trigger a worker suspension and confirm state recovery via chrome.storage.local.