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 stateworker → contextextension page → workercontent script → worker

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.

One full wake-and-evict cycle of an MV3 service workerCold start, listener registration, handler execution, idle countdown and eviction, with the two points where extensions most often lose state.event queuedheap freedCold startscript parsed and ev…Listene…synchron…Handler runsyour awaitsIdle countdown~30 s with no eventsEvictedmodule s…last safe moment to call addListenerpersist anything you need next timenext event pays a full cold start
Everything above module scope is discarded at the right-hand edge of this timeline — every cycle, for the life of the extension.
 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.

What each permission tier actually unlocksLayered view from no permission through activeTab and named permissions to broad host permissions, with the review cost of each tier.No permissionextension pages, storage of your own stateno review questionsactiveTabone tab, granted by a user gestureusually accepted without justificationNamed permissionsstorage, alarms, scripting, tabseach needs a one-line reasonHost permissionshttps://*/* and friendstriggers the strictest review pathRemotely hosted codeeval, remote script tagsrejected outright under MV3
Review scrutiny rises with every layer — the cheapest permission to justify is the one you did not request.

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

Extension Updates & Data Migration

Ship an update without breaking installed users: onInstalled reasons, versioned storage migrations, orphaned content scripts, and controlling when the new version takes over.

4 topics

  • • Controlling When an Update Is Applied
  • • Fixing Extension Context Invalidated After an Update
  • • Running Data Migrations on onInstalled
  • + 1 more

Extension Security & CSP Hardening

Harden an MV3 extension against its own supply chain and the pages it touches: the default content security policy, remote code rules, sandboxed pages, message origin validation, and safe remote configuration.

2 topics

  • • Fixing CSP Violations in Extension Pages
  • • Safely Using Remote Config Without Remote Code

Offscreen Documents & DOM Access

Use chrome.offscreen to get a real DOM from an MV3 service worker: creating the document, choosing the right reason, messaging it safely, closing it, and the fallbacks for Firefox and Safari.

2 topics

  • • Parsing HTML Without innerHTML in MV3
  • • Playing Audio from an Extension

Store Submission, Permissions & Compliance

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.

4 topics

  • • Publishing to Firefox Add-ons and Safari
  • • Writing a Permission Justification That Passes
  • • Passing Chrome Web Store Review
  • + 1 more

Content Scripts & DOM Injection

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

  • • Injecting Content Scripts into Dynamic Iframes
  • • Best Practices for Content Script Isolation in MV3

Extension Popup Architecture

Build MV3-compliant browser extension popups: ephemeral lifecycle, storage hydration, service worker messaging, and cross-browser constraints explained.

2 topics

  • • Opening a Popup from the Service Worker in MV3
  • • Managing Extension State Across Popup Reloads in MV3

Options Page Configuration

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.

2 topics

  • • Opening the Options Page Programmatically
  • • Showing a First-Run Setup Page After Install

Service Worker Fundamentals

Master MV3 service worker registration, the install/activate/idle lifecycle, event-driven design, state hydration from chrome.storage, and alarm-based scheduling.

4 topics

  • • Background Script Support Across Browsers
  • • Keeping Service Workers Alive During Long Tasks
  • • How to Migrate from Manifest V2 to V3 Service Workers
  • + 1 more