MV3 Architecture & Extension Lifecycle
Master Manifest V3 service workers, content scripts, popup & options UI, and store compliance — with cross-browser patterns for Chrome, Firefox, and Safari.
Manifest V3 replaces persistent background pages with event-driven service workers, and that single decision ripples through every other architectural choice an extension author makes. State that used to live in a module-scope variable must now live in chrome.storage. Network interception that used to happen in a blocking listener must now be expressed as declarative rules. DOM manipulation that used to run in a long-lived shared context must now be explicitly injected into the target tab. Start with Service Worker Fundamentals — the lifecycle constraints there are the root cause of the most common MV3 bugs, and understanding them unlocks every other topic in this section.
Manifest declaration surface
The manifest is the authoritative contract between your extension and the browser. Every execution context, permission, and UI entry point is declared here — nothing is implicit. The snippet below covers the full declaration surface for the topics in this section.
1{
2 "manifest_version": 3,
3 "name": "My Extension",
4 "version": "1.0",
5
6 // Background context — event-driven, non-persistent
7 "background": {
8 "service_worker": "sw.js",
9 "type": "module" // ES modules; requires Chrome 91+, Firefox 109+, Safari 16.4+
10 },
11
12 // Content scripts — injected into matching pages, run in an isolated world
13 "content_scripts": [
14 {
15 "matches": ["https://*/*"],
16 "js": ["content.js"],
17 "run_at": "document_idle" // safe default; DOM is ready but page scripts have run
18 }
19 ],
20
21 // Action — the toolbar icon that opens the popup
22 "action": {
23 "default_popup": "popup.html",
24 "default_title": "My Extension"
25 },
26
27 // Options UI — full-page settings surface
28 "options_page": "options.html",
29
30 // Permissions — declare the minimum required set
31 "permissions": ["storage", "activeTab", "scripting"],
32 "host_permissions": ["https://*/*"]
33}
Execution context: Parsed by the browser at install and on every update. Chrome and Edge enforce manifest_version: 3 strictly; Firefox has supported MV3 since v109 but retains some MV2 compatibility shims; Safari has required MV3 for new submissions since Safari 16.4. The type: "module" flag on the service worker enables top-level await and ES module imports — essential for keeping listener registration at the top level where the runtime expects it.
Service worker lifecycle
The background service worker starts on demand and terminates after roughly 30 seconds of inactivity. This is not a bug — it is a deliberate resource constraint, and every robust extension must design around it. Module-scope variables do not survive termination. Timers set with setTimeout or setInterval are unreliable across restarts; use chrome.alarms instead.
1// sw.js — top-level listener registration (mandatory — do not nest inside async functions)
2chrome.runtime.onInstalled.addListener(async ({ reason }) => {
3 if (reason === "install") {
4 await chrome.storage.local.set({ schemaVersion: 1, enabled: true });
5 }
6});
7
8// chrome.alarms survives service worker restarts; setInterval does not
9chrome.alarms.create("heartbeat", { periodInMinutes: 1 });
10
11chrome.alarms.onAlarm.addListener(async (alarm) => {
12 if (alarm.name !== "heartbeat") return;
13 const { enabled } = await chrome.storage.local.get("enabled");
14 if (enabled) await runPeriodicTask();
15});
16
17async function runPeriodicTask() {
18 // Reads from storage — the only safe source of truth across restarts
19 const { lastSync } = await chrome.storage.local.get("lastSync");
20 console.log("Last sync:", lastSync);
21 await chrome.storage.local.set({ lastSync: Date.now() });
22}
Execution context: Service worker global scope — no window, no document, no localStorage. All chrome.* APIs that return Promises are safe to await. Register all event listeners at the top level synchronously before any await; the runtime scans for listeners during the install event and will not fire events whose listeners were registered after an await boundary. Firefox keeps the service worker alive slightly longer than Chrome under some conditions; Safari is the most aggressive about early termination and does not guarantee alarm precision below one minute.
Content scripts and DOM injection
Content scripts run in an isolated JavaScript world — they share the page’s DOM but not its JavaScript heap. A content script cannot call functions defined by the page, and page scripts cannot call functions defined by the content script. This isolation is a security boundary, not an accident, and crossing it requires window.postMessage or the MAIN world injection option.
The gotcha that catches most developers: content scripts declared in manifest.json under content_scripts are injected automatically on matching pages, but programmatic injection via chrome.scripting.executeScript requires the scripting permission and either activeTab (for one-time user-gesture-triggered injection) or explicit host_permissions.
1// content.js — runs in isolated world, has DOM access
2(function () {
3 "use strict";
4
5 // Safe: read from storage directly (storage permission grants cross-context access)
6 chrome.storage.local.get("highlightColor").then(({ highlightColor }) => {
7 if (!highlightColor) return;
8 document.querySelectorAll("p").forEach((p) => {
9 p.style.backgroundColor = highlightColor;
10 });
11 });
12
13 // React to messages from the service worker
14 chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
15 if (msg.type === "PING") {
16 sendResponse({ alive: true, url: location.href });
17 }
18 });
19})();
Execution context: Isolated world within the renderer process for the target tab. Has access to document, window, and chrome.storage (if storage permission is declared), but not to page-global variables or closed-over module scope from page scripts. chrome.scripting is not available here — injection must be requested from the service worker. Firefox runs content scripts in the same isolated world model; Safari restricts world: "MAIN" injection and requires the standard isolated world for most use cases.
Popup and options UI
The popup and options page are ordinary HTML pages that happen to be bundled with the extension. They run in a dedicated renderer process, have full DOM access, and can call all chrome.* APIs that are available to extension pages. The critical difference from content scripts: they share no execution context with each other or with the service worker — every state update must go through chrome.storage or chrome.runtime.sendMessage.
The popup is destroyed when the user closes it. Any state that must survive popup close must be written to chrome.storage before the popup window disappears; do not store it in a popup-scoped variable and expect it to be there on the next open.
1// popup.js — runs in the popup's renderer context
2document.addEventListener("DOMContentLoaded", async () => {
3 // Always read initial state from storage, never from a module-scope variable
4 const { enabled, count } = await chrome.storage.local.get(["enabled", "count"]);
5
6 const toggle = document.getElementById("toggle");
7 toggle.checked = enabled ?? false;
8
9 toggle.addEventListener("change", async () => {
10 await chrome.storage.local.set({ enabled: toggle.checked });
11 // Notify the service worker so it can act immediately
12 await chrome.runtime.sendMessage({ type: "ENABLED_CHANGED", enabled: toggle.checked });
13 });
14
15 document.getElementById("count").textContent = count ?? 0;
16});
Execution context: Extension page renderer — full DOM, fetch, crypto, and all chrome.* extension APIs available. localStorage is accessible here but is scoped to the extension origin and is not shared with content scripts or the service worker; prefer chrome.storage for cross-context state. Firefox requires browser.runtime.sendMessage if you are not using a polyfill; the webextension-polyfill package resolves this without code branches. Safari enforces a strict CSP that rejects inline event handlers; all event listeners must be added via addEventListener from external script files.
The options page works identically but is opened as a full browser tab (for options_page) or an embedded iframe in chrome://extensions (for options_ui with open_in_tab: false). Read initial state from storage in DOMContentLoaded, write changes immediately on user input, and use chrome.storage.onChanged if you need real-time sync across multiple open option tabs.
1// options.js — runs in the options page renderer
2chrome.storage.onChanged.addListener((changes, area) => {
3 if (area !== "local") return;
4 if (changes.theme) {
5 applyTheme(changes.theme.newValue); // keep UI in sync if another tab changes a setting
6 }
7});
8
9async function applyTheme(theme) {
10 document.documentElement.setAttribute("data-theme", theme ?? "system");
11}
Execution context: Extension page renderer, same capabilities as the popup. chrome.storage.onChanged fires in all open extension pages simultaneously — this is the correct pattern for keeping multiple open options tabs consistent without polling. Firefox and Safari fire the same event; Safari may batch rapid changes into a single callback delivery.
Offscreen documents
Manifest V3 removed the background page, and with it the DOM that background code used for parsing HTML, playing audio, writing to the clipboard and creating object URLs. The service worker has none of these. Offscreen documents give them back: a hidden extension page, created by the worker on demand, with a full DOM, a declared set of reasons, and a lifetime you manage. Only one may exist at a time, and it outlives the worker that created it, so existence must be checked with chrome.runtime.getContexts rather than remembered in a variable.
1const exists = (await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] })).length > 0;
2if (!exists) await chrome.offscreen.createDocument({ url: "offscreen.html", reasons: ["CLIPBOARD"], justification: "Copy the export on request." });
Execution context: the service worker. Firefox needs none of this — its background event page has a DOM — and Safari has no equivalent, so a portable extension hides the difference behind a small adapter. See offscreen documents and DOM access.
Updates and data migration
An extension update replaces the service worker, reloads extension pages, clears alarms and runtime content-script registrations, and leaves content scripts in open tabs running last version’s code with no connection to the extension. Storage, dynamic rules and granted permissions carry over. The update path therefore has two jobs: migrate stored data to the new shape, and rebuild everything the update cleared. Both run from runtime.onInstalled with reason: "update", registered at the top level of the worker so the event is not missed.
1chrome.runtime.onInstalled.addListener(async ({ reason, previousVersion }) => {
2 if (reason === "update") await runMigrations(previousVersion);
3 await rebuildAlarmsAndRegistrations();
4});
Execution context: the service worker. Migrations should be numbered, idempotent steps so users who skip versions pass through every step, and readers should tolerate data from newer versions after a rollback. See extension updates and data migration.
Store submission and review
Architecture decisions show up again at publication. Every permission and host pattern in the manifest appears in the install prompt and in review; remote code is prohibited outright; and the listing, privacy disclosure and permission justifications must describe what the code actually does. Most rejections trace back to a permission declared without a visible feature, broad host access with a narrow purpose, or network behaviour the listing does not mention. Designing with optional permissions requested at the moment of use, narrow match patterns, and a single audited path for anything that leaves the device makes review routine. See store submission and permissions compliance.
Build tooling
An extension is several entry points with different constraints: a module service worker with no DOM, content scripts that must be single self-contained files, extension pages that may not contain inline scripts, and a manifest whose every path must match the output. A build configured for a web application gets several of these wrong. The durable setup builds each context as its own target, generates the manifest per browser from one typed source, verifies the output after every build, and keeps development-only code such as reload clients out of release packages by construction. See build tooling and bundlers.
1// build/verify.mjs — a check worth running after every build
2if (/\bdocument\./.test(read("dist/chrome/service-worker.js"))) throw new Error("DOM reference in worker");
Execution context: Node, in CI after the build. Output checks like this catch the mistakes configuration reviews miss.
The architecture in one sentence
Taken together, the pieces of this section describe one architecture: an event-driven service worker that holds no state of its own, surfaces that render from storage and hand work to the worker, content scripts that behave as careful guests in other people’s pages, storage as the single source of truth shared by all of them, alarms for anything deferred, and an update path that migrates data and rebuilds what the update cleared. Extensions built to that shape tend to have few lifecycle bugs, because each part assumes the others may disappear at any moment — which, in Manifest V3, they may.
Common architectural mistakes
A handful of mistakes account for most of the difficult bugs in Manifest V3 extensions, and all of them come from carrying assumptions over from Manifest V2 or from web development.
Treating the worker as a long-running program. Caches in module variables, setInterval timers and open connections all vanish when the worker is evicted. State belongs in storage; deferred work belongs in alarms.
Registering listeners late. Any addListener call that runs after an await — or from a dynamically imported module — misses the event that woke the worker. Every listener must be registered in the first synchronous pass of the worker script, as described in registering listeners at the top level.
Waiting on the worker to render. A popup or side panel that sends a message and waits for the answer before its first paint is only as fast as a cold start. Surfaces should render from storage and treat the worker as a source of corrections.
Declaring broadly and filtering at runtime. A content script matching every URL, or host access to every site, costs performance on every page and review time on every release. Narrow declarations with optional permissions requested at the moment of use cost neither.
Forgetting the update. Alarms, menus and registrations that are created only on first install disappear after the first update, and content scripts in open tabs are orphaned. The onInstalled handler must rebuild on every install reason, and content scripts must fail gracefully when their context is invalidated.
Each of these produces a bug that is intermittent, appears mostly on users’ machines, and disappears when DevTools is open — which is why recognising the pattern early saves far more time than debugging each occurrence.
When reviewing a pull request that touches the worker, the popup, a content script or the manifest, it is worth checking the change against this list explicitly. The mistakes are easy to introduce in a small diff and expensive to diagnose once they have shipped, because by then they only appear on users’ machines and rarely in a form that points back to the change responsible.
Permissions, security, and CSP
Every permission string in manifest.json is permanently visible to the Chrome Web Store review team, to Firefox Add-ons reviewers, and — for host permissions — to users at install time. The review criterion is simple: each permission must be demonstrably required by a feature the user can see. Permissions that cannot be justified cause rejection; permissions that are declared but unused trigger automatic warnings in the store listing.
Declare "activeTab" instead of broad host permissions wherever a user gesture (clicking the toolbar icon) is sufficient to trigger the privileged operation. activeTab grants temporary host-level access to the active tab without appearing in the install dialog and without requiring a host permission string. If your extension must operate on pages the user has not explicitly visited, you need explicit host_permissions.
MV3 extension pages run under a strict default Content Security Policy that blocks eval, new Function, inline <script> tags, and remote script sources. You cannot relax script-src in MV3 — Chrome rejects any attempt to add 'unsafe-eval' and the extension will fail to load. Audit your bundler output: Webpack’s default devtool: 'eval' mode and some dynamic-require transforms emit eval statements that pass local testing but fail in the packed extension.
1// sw.js — validate incoming messages before acting on them
2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
3 // Reject messages not originating from your own extension
4 if (sender.id !== chrome.runtime.id) return;
5
6 // Validate the message shape before executing privileged operations
7 if (typeof msg.type !== "string" || !ALLOWED_TYPES.has(msg.type)) {
8 console.warn("Rejected unknown message type:", msg.type);
9 return;
10 }
11
12 handleMessage(msg, sendResponse);
13 return true;
14});
15
16const ALLOWED_TYPES = new Set(["ENABLED_CHANGED", "PING", "SYNC_NOW"]);
Execution context: Service worker. sender.id is populated by the runtime and cannot be spoofed by web page content — this is the correct first check. Note that web pages can call chrome.runtime.sendMessage against any extension whose externally_connectable manifest key includes their origin; if you do not declare externally_connectable, only your own extension pages and content scripts can reach this listener.
Optional permissions declared under "optional_permissions" can be requested at runtime via chrome.permissions.request. This is the recommended approach for permissions that are not needed on install — it avoids alarming users upfront and reduces the attack surface of the extension when those features are not in use. The full workflow for runtime permission requests is covered in Store Submission & Permissions Compliance.
Cross-browser compatibility matrix
| Feature | Chrome / Edge | Firefox (≥ 109) | Safari (≥ 16.4) |
|---|---|---|---|
| MV3 service worker background | Full support | Full support; slightly longer idle window | Full support; aggressive early termination |
type: "module" in service worker | Since Chrome 91 | Since Firefox 109 | Since Safari 16.4 |
chrome.alarms | Full support; minimum 1-minute intervals | browser.alarms; same interval floor | Supported; intervals may drift significantly |
| Content scripts (manifest-declared) | Full support | Full support via browser.contentScripts | Full support |
Content scripts (world: "MAIN") | Since Chrome 111 | Not supported (Firefox 2024 roadmap) | Not supported |
chrome.scripting.executeScript | Full support | browser.scripting since Firefox 102 | Since Safari 16 |
chrome.action popup | Full support | browser.action full support | Full support |
options_page | Full support | Full support | Full support |
options_ui embedded | Full support | Full support | Opens as full tab instead |
chrome.permissions.request | Full support | browser.permissions.request | Full support; UI differs |
MV3 CSP (script-src locked) | Enforced strictly | Enforced | Enforced |
What this section covers
The guides here address each sub-system in depth. Service Worker Fundamentals covers the startup/termination cycle, top-level listener registration, keep-alive patterns, and the migration path from MV2 background pages. Content Scripts & DOM Injection explains the isolated world model, manifest vs. programmatic injection, cross-origin frame targeting, and isolation best practices. Extension Popup Architecture goes deep on state hydration across popup open/close cycles, communicating with the service worker, and managing the popup’s short lifetime. Options Page Configuration covers the options_page vs. options_ui trade-offs, form state persistence with chrome.storage, and multi-tab sync patterns. Extension Security & CSP Hardening covers the policy that refuses inline handlers and remote code, and the patterns that work within it. Offscreen Documents & DOM Access covers the one surface that gives a worker a DOM, and the narrow reasons it is allowed to exist. Extension Updates & Data Migration covers what happens when a new version lands on a running browser: resumable storage migrations, orphaned content scripts, and choosing when the update applies. Finally, Store Submission & Permissions Compliance walks through the Chrome Web Store and Firefox AMO review requirements, optional permission workflows, and the justification language reviewers look for.
Two areas that earlier material only touched now have their own depth. Build tooling and bundlers covers turning one source tree into correct packages for every browser — from bundling with Vite to generating a manifest per browser target. And the lifecycle rule behind most MV3 bugs gets a guide of its own: registering listeners at the top level.
Related
- Build Tooling & Bundlers — bundling, TypeScript and per-browser manifests.
- Service Worker Fundamentals — startup, termination, keep-alive, and MV2 migration.
- Content Scripts & DOM Injection — isolated world model and injection patterns.
- Extension Popup Architecture — state hydration, messaging, and popup lifetime.
- Options Page Configuration — settings persistence and multi-tab sync.
- Extension Security & CSP Hardening — the policy every surface runs under.
- Offscreen Documents & DOM Access — giving the worker a DOM, narrowly.
- Extension Updates & Data Migration — shipping a new version onto a running browser.
- Store Submission & Permissions Compliance — review requirements and optional permissions.
- Core APIs & Cross-Browser Data Management — storage, messaging, and declarativeNetRequest patterns.
- UI/UX Patterns & Interactive Components — popup design, options layouts, and keyboard shortcuts.