Extension Popup Architecture
Build MV3-compliant browser extension popups: ephemeral lifecycle, storage hydration, service worker messaging, and cross-browser constraints explained.
Build MV3-compliant browser extension popups: ephemeral lifecycle, storage hydration, service worker messaging, and cross-browser constraints explained.
The popup is the most visible surface of any browser extension — and one of the most architecturally tricky. Under Manifest V3, the popup is an ephemeral HTML document: it loads fresh on every click and unloads completely when the user dismisses it. Any JavaScript heap state, pending timers, or DOM mutations vanish the moment the popup closes. Developers who carry MV2 mental models into MV3 — expecting a persistent background page to hold data between popup opens — run into silent state resets, blank UIs after idle periods, and message-passing failures against a service worker that has already been terminated.
This page covers the full architecture of the MV3 popup: how to register it in the manifest, how to read and write state reliably, how to communicate with the service worker, when to reach for the side panel instead, and what hard constraints the browser enforces regardless of how the code is written. Everything here fits within the broader Manifest V3 Architecture & Extension Lifecycle and assumes readers are already comfortable with the event-driven model that replaced persistent backgrounds.
Before adding a popup, confirm these pieces are already in place:
"action" key in manifest.json — required to register default_popup and the toolbar icon"storage" permission — needed for chrome.storage.local and chrome.storage.session reads on popup open"background"."service_worker" — the popup must have somewhere to send messagesdefault_popup — Chrome refuses to open the popup if the file is missing at install timeunsafe-inline in extension pages; all scripts must be external .js fileseval() or new Function() calls in popup scripts — these violate the extension CSP regardless of the manifest’s content_security_policy fieldThe popup is declared in the "action" object. A bare-minimum registration looks like this:
1{
2 "manifest_version": 3,
3 "name": "My Extension",
4 "version": "1.0.0",
5 "action": {
6 // The HTML file that renders when the toolbar icon is clicked.
7 "default_popup": "popup.html",
8 // Sizes must be 16, 32, 48, and 128. Supply all four.
9 "default_icon": {
10 "16": "icons/icon-16.png",
11 "32": "icons/icon-32.png",
12 "48": "icons/icon-48.png",
13 "128": "icons/icon-128.png"
14 },
15 "default_title": "Open My Extension"
16 },
17 "background": {
18 "service_worker": "sw.js",
19 "type": "module"
20 },
21 "permissions": ["storage"]
22}
Execution context: Parsed by the browser at install and update time — no JavaScript runs here. Chrome, Firefox, and Safari all support "action" in MV3, but Firefox additionally requires "browser_action" in MV2 manifests. Safari requires that default_icon paths resolve to actual PNG files; SVG icons are silently ignored on Safari 15 and below.
Every time the user clicks the toolbar icon, the browser creates a fresh HTML document for the popup — no variables carry over from the previous open. The first thing your popup script should do is read persisted state from chrome.storage and apply it to the DOM synchronously before any animations or network calls.
1// popup.js — entry point loaded by popup.html via <script src="popup.js" defer>
2document.addEventListener('DOMContentLoaded', async () => {
3 // Read all needed keys in one call to minimize IPC round-trips.
4 const defaults = { darkMode: false, lastTabUrl: '', syncEnabled: true };
5 const stored = await chrome.storage.local.get(defaults);
6
7 // Apply state to DOM before the user sees anything.
8 document.documentElement.classList.toggle('dark', stored.darkMode);
9 document.getElementById('url-display').textContent = stored.lastTabUrl || '—';
10 document.getElementById('sync-toggle').checked = stored.syncEnabled;
11
12 // Persist changes immediately — don't wait for popup close.
13 document.getElementById('sync-toggle').addEventListener('change', (e) => {
14 chrome.storage.local.set({ syncEnabled: e.target.checked });
15 });
16});
Execution context: Runs in the popup page’s renderer process, which has full DOM access (document, window, location) and access to all chrome.* extension APIs. There is no importScripts() — use standard ES module <script type="module"> or a bundler output. chrome.storage.local reads do not require the service worker to be awake; the storage daemon is a separate browser process. Firefox resolves chrome.storage as a promise natively; Safari requires the webextension-polyfill shim for consistent async behavior.
For deeper patterns around keeping this state correct across service worker restarts, see Managing extension state across reloads.
For data that requires computation, network access, or privileged APIs the popup cannot call directly, send a message to the service worker and await the response. Use chrome.runtime.sendMessage for one-off requests and chrome.runtime.connect when you need a long-lived channel (for example, streaming progress updates during a background operation).
1// popup.js — one-off request with timeout guard
2async function askServiceWorker(type, payload, timeoutMs = 3000) {
3 return new Promise((resolve, reject) => {
4 const timer = setTimeout(
5 () => reject(new Error(`Service worker did not respond to "${type}" within ${timeoutMs}ms`)),
6 timeoutMs
7 );
8
9 chrome.runtime.sendMessage({ type, payload }, (response) => {
10 clearTimeout(timer);
11 if (chrome.runtime.lastError) {
12 reject(new Error(chrome.runtime.lastError.message));
13 return;
14 }
15 resolve(response);
16 });
17 });
18}
19
20// Usage:
21document.getElementById('sync-btn').addEventListener('click', async () => {
22 try {
23 const result = await askServiceWorker('SYNC_NOW', { force: true });
24 showStatus(result.ok ? 'Synced.' : 'Sync failed.');
25 } catch (err) {
26 showStatus('Service worker unreachable — try again.');
27 }
28});
Execution context: The chrome.runtime.sendMessage call crosses a process boundary: the popup renderer sends an IPC message to the browser’s extension host, which wakes the service worker if it is sleeping and delivers the message. The service worker must call sendResponse synchronously or return true from its onMessage listener to hold the channel open for async work. In Firefox, browser.runtime.sendMessage returns a native Promise; wrapping it in the callback form shown above keeps the code compatible with Chrome and Safari without a polyfill. Do not assume the service worker is already awake when the popup opens — the timeout guard above prevents the UI from hanging indefinitely.
For the inverse scenario — triggering a popup open from background code — see Opening a popup from the service worker.
The service worker must register its onMessage listener at the top level so it is in place before Chrome terminates the startup phase.
1// sw.js — top-level listener, not inside an async callback
2chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
3 // Return true to signal that sendResponse will be called asynchronously.
4 if (message.type === 'SYNC_NOW') {
5 handleSync(message.payload).then(sendResponse).catch((err) => {
6 sendResponse({ ok: false, error: err.message });
7 });
8 return true;
9 }
10});
11
12async function handleSync({ force }) {
13 // Fetch remote data, write to chrome.storage, return summary.
14 const data = await fetch('https://api.example.com/sync').then((r) => r.json());
15 await chrome.storage.local.set({ lastSync: Date.now(), data });
16 return { ok: true, count: data.length };
17}
Execution context: Runs in the service worker scope — no window, no document, no localStorage. fetch is available. The listener must be registered synchronously at module evaluation time; listeners registered inside addEventListener('install', ...) or other async callbacks may be missed if the service worker wakes up for a subsequent event. Chrome terminates the service worker approximately 30 seconds after the last event completes; Firefox gives a longer window; Safari is the most aggressive about reclaiming memory. See Persistent vs non-persistent service workers explained for keep-alive strategies.
The popup is not an appropriate host for long-running tasks. If the user closes the popup mid-operation, the document unloads and any in-flight fetch, setTimeout, or Promise chain is silently cancelled. Additionally, Chrome limits popup dimensions to approximately 800 × 600 px and enforces a strict CSP that blocks any resource-intensive inline computation.
Delegate to the service worker instead:
chrome.downloads.download() called from the service worker, not the popupchrome.alarms in the service worker; alarms fire even when the popup is closedchrome.storage; let the popup read the finished resultThis keeps the popup fast to open (no blocking work on load) and resilient to the user dismissing it before an operation completes. If you need a persistent UI surface for long-running tasks, consider the side panel instead.
The side panel (chrome.sidePanel, available Chrome 114+ and Firefox 131+) is appropriate when the user needs a persistent, multi-step interface that should survive navigation between tabs. The popup auto-closes and is best for quick interactions: toggling a feature, copying a value, initiating a one-shot action.
| Scenario | Use popup | Use side panel |
|---|---|---|
| Toggle a setting | Yes | No |
| Show current page stats | Yes | Maybe |
| Multi-step workflow | No | Yes |
| Persist across tab switches | No | Yes |
| Initiated by toolbar click | Natural | Requires explicit open call |
The side panel lives in the UI/UX patterns & interactive components section rather than here because its lifecycle differs substantially — it does not unload on dismiss and can hold WebSocket connections across page navigations.
<script> tags without src and any eval() variant. All JavaScript must live in external files.window.opener. Extension popups cannot reference or communicate with the browser window that spawned them through the DOM.overflow: auto explicitly.chrome.runtime.getBackgroundPage() is gone. MV3 removed the persistent background page. There is no synchronous handle to the background context; all communication is asynchronous via message passing or chrome.storage.Map, Set, or objects with circular references through chrome.runtime.sendMessage. Use plain JSON-compatible objects.sendMessage, but this takes tens to hundreds of milliseconds. Build a loading state for any data that requires the background.Chrome / Chromium-based browsers (Edge, Opera, Brave)
Chrome 88+ is the reference implementation for MV3 popups. All features described on this page — chrome.action, chrome.storage.session, chrome.runtime.sendMessage — are stable. Edge inherits Chromium behavior but may show a visible popup border width difference of 1–2 px due to UI chrome. Chromium enforces the 30-second service worker idle termination most strictly.
Firefox
Firefox shipped MV3 support incrementally starting in Firefox 109. The action API is available, and browser.storage.local is promise-based natively. The service worker idle timer is longer than Chrome’s — Firefox keeps the background alive for longer periods under user activity, which can mask state-persistence bugs during development. Always test after explicitly stopping the service worker via about:debugging. Firefox does not yet support chrome.sidePanel, so popup-based workflows remain the primary persistent UI option for Firefox targets.
Safari
Safari Web Extensions use the same action.default_popup key and support browser.storage.local, but several differences require attention. Safari 15 and earlier ignore SVG toolbar icons — supply all four PNG sizes. The webextension-polyfill library is required for promise-based chrome.* API calls. Safari enforces stricter popup sizing and requires explicit width and height values in the popup HTML’s <body> or CSS; without them, the popup may render as a narrow sliver. chrome.storage.session is not available in Safari 15; use chrome.storage.local with a session flag instead and clear it on chrome.runtime.onStartup.
Shared workaround pattern
1// compatibility.js — import before any chrome.* calls on Safari
2if (typeof browser !== 'undefined' && typeof chrome === 'undefined') {
3 globalThis.chrome = browser;
4}
Execution context: Runs in the popup renderer before any extension API calls. This shim is a stopgap; for production extensions targeting all three browsers, use the official webextension-polyfill package, which handles edge cases around callback vs. promise APIs more robustly.
local vs sync vs session, and cross-browser storage behaviorLearn how to programmatically open a Chrome extension popup from a service worker using chrome.action.openPopup(), with fallbacks for older browsers and no-gesture contexts.
Fix the stale-state bug in MV3 popups: hydrate from chrome.storage.local on DOMContentLoaded, persist eagerly on every interaction, and handle service worker eviction reliably.