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.
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.
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.
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 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.
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.
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.
| 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 |
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. 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.
Submit MV3 extensions to Chrome Web Store, Firefox AMO and Safari, justify permissions under least-privilege, meet privacy disclosure requirements and avoid common rejection causes.
2 topics
Inject JavaScript and CSS into web pages with MV3 content scripts — isolated worlds, declarative vs programmatic injection, run_at timing, MAIN world risks, and Shadow DOM UI patterns.
2 topics
Build MV3-compliant browser extension popups: ephemeral lifecycle, storage hydration, service worker messaging, and cross-browser constraints explained.
2 topics
Register options_ui or options_page in Manifest V3, persist user preferences through chrome.storage.sync, and keep every extension context in sync via onChanged.
Master MV3 service worker registration, the install/activate/idle lifecycle, event-driven design, state hydration from chrome.storage, and alarm-based scheduling.
3 topics