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.

MV3 extension architecture: four execution contexts and their relationshipsThe service worker, popup, options page, and content script each run in an isolated context. All cross-context communication goes through chrome.runtime messaging or chrome.storage; no shared memory exists between them.Service Workerevent-driven · no DOM · ~30 s idle limitPopupextension page · short-livedOptions Pageextension page · persistent tabContent Scriptisolated world · page DOM accesschrome.storageshared durable stateSW → contextcontext → SWcontent script → SW

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.

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.

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

FeatureChrome / EdgeFirefox (≥ 109)Safari (≥ 16.4)
MV3 service worker backgroundFull supportFull support; slightly longer idle windowFull support; aggressive early termination
type: "module" in service workerSince Chrome 91Since Firefox 109Since Safari 16.4
chrome.alarmsFull support; minimum 1-minute intervalsbrowser.alarms; same interval floorSupported; intervals may drift significantly
Content scripts (manifest-declared)Full supportFull support via browser.contentScriptsFull support
Content scripts (world: "MAIN")Since Chrome 111Not supported (Firefox 2024 roadmap)Not supported
chrome.scripting.executeScriptFull supportbrowser.scripting since Firefox 102Since Safari 16
chrome.action popupFull supportbrowser.action full supportFull support
options_pageFull supportFull supportFull support
options_ui embeddedFull supportFull supportOpens as full tab instead
chrome.permissions.requestFull supportbrowser.permissions.requestFull support; UI differs
MV3 CSP (script-src locked)Enforced strictlyEnforcedEnforced

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. 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.