A comprehensive technical guide to Chrome Manifest V3 architecture — service workers, content scripts, message passing, storage, and the real-world challenges of building production extensions for AI platforms.
Founder & Lead Engineer at Toffee
In January 2023, Google began enforcing Manifest V3 (MV3) as the required format for all new Chrome extensions, deprecating the decade-old Manifest V2. This was not a minor version bump — it fundamentally restructured how browser extensions operate, affecting security, performance, and developer experience.
For developers building AI-related extensions, MV3 introduced both challenges and opportunities. The migration from persistent background pages to ephemeral service workers required rethinking state management. Stricter Content Security Policies improved security but complicated dynamic code loading. New declarative APIs replaced powerful but abuse-prone imperative ones.
This article provides a production-focused guide to MV3 architecture, drawn from practical experience building Toffee — a browser extension that captures, compresses, and transfers AI conversation context across platforms.
A Manifest V3 extension consists of several isolated execution contexts that communicate through message passing:
chrome.scripting API.The communication flow for a typical AI context capture looks like this:
User clicks "Capture" in Popup
→ Popup sends message to Service Worker
→ Service Worker injects content script via chrome.scripting
→ Content script reads AI platform DOM
→ Content script sends extracted data to Service Worker
→ Service Worker compresses and stores in IndexedDB
→ Service Worker sends confirmation to Popup
→ Popup updates UI
Each arrow represents a message passing boundary. Understanding these boundaries is essential for debugging MV3 extensions.
The most impactful change in MV3 is the replacement of persistent background pages with ephemeral service workers. In MV2, the background page ran continuously, maintaining state in memory. In MV3, the service worker is terminated after 30 seconds of inactivity.
This has profound implications for state management:
// ❌ This will NOT work in MV3 — variable is lost on restart
let capturedData: ConversationData[] = [];
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'CAPTURE') {
capturedData.push(msg.data); // Lost when SW terminates
}
});
// ✅ Use persistent storage instead
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'CAPTURE') {
// IndexedDB persists across service worker restarts
db.captures.put(msg.data);
}
});
Any state that must survive a service worker restart must be persisted to IndexedDB, Chrome storage (chrome.storage.local), or session storage (chrome.storage.session).
For long-running operations like AI compression (which can take 5-15 seconds), the service worker might terminate mid-operation. There are several patterns to prevent this:
// Pattern 1: Use chrome.runtime.onMessage's sendResponse
// The service worker stays alive while awaiting sendResponse
chrome.runtime.onMessage.addListener(
(message, sender, sendResponse) => {
if (message.type === 'COMPRESS') {
compressConversation(message.data)
.then((bundle) => sendResponse({ bundle }))
.catch((err) => sendResponse({ error: err.message }));
return true; // Keep alive until sendResponse is called
}
}
);
// Pattern 2: Self-messaging ping to reset the 30s timer
async function longRunningTask() {
const keepAlive = setInterval(() => {
chrome.runtime.sendMessage({ type: 'KEEPALIVE' });
}, 20000);
try {
const result = await expensiveOperation();
return result;
} finally {
clearInterval(keepAlive);
}
}
Pattern 1 is preferred because it uses the platform's own lifecycle management. Pattern 2 is a workaround for operations that do not originate from a message handler.
MV3 supports two methods of injecting content scripts, each with distinct trade-offs:
{
"content_scripts": [{
"matches": ["https://chatgpt.com/*"],
"js": ["content/chatgpt.js"],
"run_at": "document_idle"
}]
}
Static scripts are automatically injected when the user navigates to a matching URL. They are simple to configure but run on every page load, consuming resources even when the user does not intend to capture context.
// Inject only when user requests a capture
async function injectContentScript(tabId: number, platform: string) {
const scriptFile = `content/${platform}.js`;
try {
await chrome.scripting.executeScript({
target: { tabId },
files: [scriptFile],
world: 'ISOLATED', // Default — safe sandbox
});
} catch (error) {
// Fallback: some sites reject ISOLATED world injection
await chrome.scripting.executeScript({
target: { tabId },
files: [scriptFile],
world: 'MAIN', // Runs in page's JS context
});
}
}
Programmatic injection is more efficient because scripts are loaded only when needed. However, it requires the scripting permission and host_permissions for the target domains.
The fallback from ISOLATED to MAIN world is important for platforms that restrict extension execution. Some enterprise-deployed AI tools block content scripts in the isolated world via their Content Security Policy. Injecting into the MAIN world bypasses this restriction, though it requires extra care to avoid variable name conflicts with the page's own scripts.
MV3 extensions have several storage options, each suited to different data types:
| Storage | Capacity | Access From | Best For |
|---|---|---|---|
chrome.storage.local | 10 MB (unlimited with permission) | Service Worker, Popup, Content Scripts | Settings, preferences, small state |
chrome.storage.session | 10 MB | Service Worker, Popup | Ephemeral session state (cleared on browser close) |
| IndexedDB | Unlimited (browser-dependent) | Service Worker, Popup | Large data: conversation bundles, compressed context |
chrome.storage.sync | 100 KB total | All contexts | Cross-device settings sync |
For AI context bundles that can be 10–100 KB each, IndexedDB is the only practical choice. Using Dexie — a typed wrapper around IndexedDB — provides a developer experience comparable to an ORM:
import Dexie, { Table } from 'dexie';
interface StoredBundle {
id: string;
platform: string;
title: string;
compressedContext: string;
metrics: BundleMetrics;
createdAt: Date;
synced: boolean;
}
class ToffeeDB extends Dexie {
bundles!: Table;
constructor() {
super('ToffeeDB');
this.version(1).stores({
bundles: 'id, platform, createdAt, synced',
});
}
}
export const db = new ToffeeDB();
Dexie handles schema versioning, indexing, and querying with a clean Promise-based API. This is dramatically more productive than the raw IndexedDB API, which uses an event-driven pattern that is difficult to compose with async/await code.
MV3 enforces a strict Content Security Policy for all extension contexts:
script-src 'self')eval() or new Function()This is a significant security improvement over MV2, which allowed extensions to fetch and execute arbitrary code from remote servers. For users, this means an MV3 extension's behavior is fully auditable from its packaged source code — it cannot download and run new code after installation.
For developers, this means build tooling must produce self-contained bundles. Dynamic imports are allowed within the extension's own files but cannot reference external URLs. Vite and Webpack are commonly used to produce these bundles, with special plugins like @crxjs/vite-plugin that handle the unique requirements of extension bundling.
The security model also affects how content scripts interact with AI platform pages. Content scripts in the ISOLATED world cannot access the page's JavaScript runtime, which means they cannot call functions defined by the page or access React component state. They can only read and modify the DOM.
Debugging MV3 extensions requires understanding where each component runs:
chrome://extensions → "Inspect views: service worker." The DevTools opens in a separate window.A common pitfall: service worker logs disappear when the worker is terminated. To preserve logs across restarts, either persist them to storage or keep the service worker's DevTools window open (which prevents termination — useful during development but not in production).
For automated testing, tools like Puppeteer and Playwright support loading unpacked extensions. This enables end-to-end testing of the full capture-compress-store pipeline without manual interaction. Combining this with CI/CD ensures that platform adapter updates do not break existing functionality.
Manifest V3 replaces persistent background pages with ephemeral service workers, enforces stricter Content Security Policies that prevent remote code execution, introduces the declarativeNetRequest API to replace webRequest blocking, and requires static content script declaration. These changes improve security and performance at the cost of development complexity.
Chrome terminates idle service workers to conserve system resources. Extensions that run persistent background processes can drain battery and memory. The 30-second timeout ensures extensions only consume resources when actively processing events.
Firefox supports MV3 with some differences. Firefox allows both MV2 and MV3, and its MV3 implementation retains some MV2 features like Event Pages. Extensions targeting both Chrome and Firefox need to account for these differences in their build configuration.
A technical deep dive into how browser extensions extract conversation data from AI platforms like ChatGPT, Claude, and Gemini using DOM parsing, Shadow DOM traversal, content scripts, and Manifest V3 architecture.
Learn how AI context transfer works, why language models lose memory between sessions, and how protocols like Toffee compress and port conversation context across ChatGPT, Claude, and Gemini without data loss.
Practical techniques for reducing token usage in AI applications — from prompt engineering and context pruning to semantic deduplication and budget-aware injection strategies.