Managing Extension State Across Popup Reloads in MV3
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.
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.
Every MV3 developer runs into the same maddening symptom eventually: the user opens your extension popup, changes a setting, closes it, and reopens it a minute later — only to find the UI reset to default values. The fix requires understanding why popup state disappears and adopting a storage-first architecture. This page walks through that architecture step by step. If you are new to how popups fit into the MV3 model, start with the extension popup architecture overview.
The popup and the service worker each have their own lifecycle, and neither is long-lived in MV3.
The popup is destroyed the instant the user clicks away or closes it. Any JavaScript variables created during that session — counters, form values, toggle states — are garbage-collected immediately. This behaviour is the same in MV2 and MV3. What changed in MV3 is the background context. In MV2 you could store ephemeral state in the background page, a persistent document that lived as long as the browser profile was open. In MV3 that background page is replaced with a service worker, which Chrome terminates after roughly 30 seconds of inactivity. Module-level variables and anything written to globalThis vanish when the worker is evicted.
User interaction timeline
─────────────────────────────────────────────────────────────────
[Popup opens] → popup.js runs → in-memory state lives here
[Popup closes] → document destroyed → state gone
↓
~30 s idle passes
↓
Service worker evicted
module-level vars gone
↓
[Popup opens] → popup.js runs again → in-memory state starts from scratch
The only layer that survives both evictions is chrome.storage. It is disk-backed, asynchronous, and accessible from every extension context — popup, service worker, content script, and options page — without any shared-memory tricks. Two API surfaces matter here:
chrome.storage.local — up to 10 MB by default (the unlimitedStorage permission removes the cap), scoped to the local device, never synced. This is the right choice for UI state that is per-device and potentially large.chrome.storage.session — lives for the duration of the browser session and is not written to disk. Useful for within-session-only values such as a temporary auth token you do not want persisted across browser restarts.Do not reach for localStorage. It is scoped to the popup document’s origin in the renderer process, the service worker cannot read it, and it dies with the popup anyway. Do not call chrome.runtime.getBackgroundPage() — that API was removed in MV3 because there is no background page to retrieve.
The service worker is responsible for initialising storage with sensible defaults the first time the extension is installed or updated. Using chrome.runtime.onInstalled guarantees this runs exactly once per install event, not on every service worker restart.
1// service-worker.js
2const DEFAULT_STATE = {
3 schemaVersion: 1,
4 featureEnabled: false,
5 badgeColor: "#4a90e2",
6 lastQuery: "",
7};
8
9chrome.runtime.onInstalled.addListener(async ({ reason }) => {
10 if (reason === "install") {
11 await chrome.storage.local.set(DEFAULT_STATE);
12 }
13
14 if (reason === "update") {
15 // Schema migration: read existing data, merge in new keys, write back.
16 const existing = await chrome.storage.local.get(null);
17 if (!existing.schemaVersion || existing.schemaVersion < 1) {
18 await chrome.storage.local.set({
19 ...DEFAULT_STATE,
20 ...existing, // preserve existing user values
21 schemaVersion: 1, // stamp the new schema version
22 });
23 }
24 }
25});
Execution context: Runs inside the MV3 service worker. Has full access to chrome.storage, chrome.runtime, and all Chrome extension APIs. No DOM is available. Chrome evicts this worker after ~30 s of inactivity, but onInstalled fires during the worker startup triggered by the install event itself, so eviction during this handler is not a concern. Firefox and Safari also fire onInstalled reliably on first install and on extension update.*
When the popup document loads, read state from chrome.storage.local immediately and apply it to the DOM before the user sees anything. Do not rely on any cached variable from a previous popup session — that memory is gone.
1// popup.js
2let state = {};
3
4async function hydrateState() {
5 const stored = await chrome.storage.local.get([
6 "featureEnabled",
7 "badgeColor",
8 "lastQuery",
9 ]);
10
11 state = stored;
12 applyStateToUI(state);
13}
14
15function applyStateToUI({ featureEnabled, badgeColor, lastQuery }) {
16 document.getElementById("feature-toggle").checked = featureEnabled ?? false;
17 document.getElementById("color-input").value = badgeColor ?? "#4a90e2";
18 document.getElementById("query-input").value = lastQuery ?? "";
19}
20
21document.addEventListener("DOMContentLoaded", hydrateState);
Execution context: Runs in the popup renderer process — a standard browser document with full DOM access. chrome.storage.local.get is available because the popup is part of the extension origin. In Chrome, DOMContentLoaded fires reliably before the user can interact with the UI. Firefox behaves identically. Safari with the webextension-polyfill wrapper requires that browser.storage.local.get is used instead of the Chrome-namespaced call, or that the polyfill normalises the namespace at load time.*
The most common mistake is writing to storage only when the popup closes — relying on beforeunload or visibilitychange as flush points. Neither is reliable for popups. Chrome does not guarantee beforeunload fires when an extension popup is dismissed by clicking away. visibilitychange and pagehide fire more consistently but still arrive after the document is already being torn down, which can race with a pending async storage write.
The correct approach is to persist every meaningful state change immediately after it happens, with debouncing applied to text inputs to avoid flooding storage on every keystroke.
1// popup.js (continued)
2
3function debounce(fn, ms) {
4 let timer;
5 return (...args) => {
6 clearTimeout(timer);
7 timer = setTimeout(() => fn(...args), ms);
8 };
9}
10
11async function persistState(patch) {
12 state = { ...state, ...patch };
13 try {
14 await chrome.storage.local.set(patch);
15 } catch (err) {
16 console.error("Storage write failed:", err);
17 }
18}
19
20const persistDebounced = debounce(persistState, 300);
21
22document.getElementById("feature-toggle").addEventListener("change", (e) => {
23 persistState({ featureEnabled: e.target.checked });
24});
25
26document.getElementById("color-input").addEventListener("input", (e) => {
27 persistDebounced({ badgeColor: e.target.value });
28});
29
30document.getElementById("query-input").addEventListener("input", (e) => {
31 persistDebounced({ lastQuery: e.target.value });
32});
Execution context: Still the popup renderer. chrome.storage.local.set is async and returns a Promise; in Chrome and Firefox it resolves after the data is committed. In Safari (via webextension-polyfill) the same semantics apply but silent failures have been observed on low-disk devices in some Safari versions. Always attach a .catch handler or wrap in try/catch — the error handler above ensures failures surface in the popup DevTools console rather than disappearing silently.*
The service worker, a content script, or another popup window can all write to chrome.storage.local while your popup is open. If you ignore those writes your UI will display stale data. Subscribe to chrome.storage.onChanged in the popup so it stays in sync with changes arriving from outside.
1// popup.js (continued)
2
3chrome.storage.onChanged.addListener((changes, areaName) => {
4 if (areaName !== "local") return;
5
6 const patch = {};
7 for (const [key, { newValue }] of Object.entries(changes)) {
8 if (key in state) {
9 patch[key] = newValue;
10 }
11 }
12
13 if (Object.keys(patch).length > 0) {
14 state = { ...state, ...patch };
15 applyStateToUI(state);
16 }
17});
Execution context: The onChanged listener runs in the popup renderer and fires whenever any extension context writes to the named storage area. Chrome and Firefox both deliver these events within the same event loop turn after the storage write commits. Safari has historically batched storage events with a small delay; if strict consistency is required, re-read from storage inside the handler rather than trusting only the newValue provided by the event.*
try/catch and implement a render fallback for the brief period when storage is being read.browser.storage.local (not chrome.storage.local). The webextension-polyfill library normalises both namespaces. Storage write errors can be silent on low-disk devices — add error handling around every set call. chrome.storage.session is not available in Safari as of Safari 17; fall back to chrome.storage.local with a session-stamped key if you need within-session-only state on all three browsers.chrome.storage.sync vs chrome.storage.local: Sync storage replicates data across the user’s signed-in Chrome instances, which suits user preferences but carries a 100 KB total quota and per-item write rate limits. UI state — scroll position, last open tab, form drafts — belongs in local, not sync. See the local vs sync storage performance comparison for detailed quota numbers and write throughput benchmarks.After applying the pattern above, reproduce the original bug scenario and confirm it is resolved:
chrome://extensions, locate your extension, and click the Service Worker link to open its DevTools panel. Click Stop to forcibly evict the worker.To confirm storage writes are landing, right-click the open popup and choose Inspect. In the Application tab, open Extension Storage → Local. You should see your keys listed with their current values, updating in real time as you interact with the UI controls.
For automated testing, use the @webext-core/fake-browser or jest-webextension-mock packages to stub chrome.storage.local. Assert that after simulating an input event, chrome.storage.local.get returns the updated value:
1// popup.test.js (jest + jest-webextension-mock)
2it("persists featureEnabled when toggle changes", async () => {
3 document.body.innerHTML = '<input type="checkbox" id="feature-toggle">';
4 await import("./popup.js");
5
6 const toggle = document.getElementById("feature-toggle");
7 toggle.checked = true;
8 toggle.dispatchEvent(new Event("change"));
9
10 // Flush async microtasks
11 await new Promise((resolve) => setTimeout(resolve, 0));
12
13 const { featureEnabled } = await chrome.storage.local.get("featureEnabled");
14 expect(featureEnabled).toBe(true);
15});
Execution context: Test runner environment (Node.js + jsdom). chrome.storage.local is provided by the mock library. No real browser APIs are involved. The setTimeout(resolve, 0) call drains the microtask queue so the async persistState call has time to complete before the assertion runs.*
No. sessionStorage is scoped to the popup document. When the popup closes the document is destroyed and sessionStorage is cleared immediately. The next time the popup opens it is a brand-new document with an empty sessionStorage. Use chrome.storage.session if you want within-browser-session persistence without writing to disk (note the Safari caveat above), or chrome.storage.local for full persistence across browser restarts.
Debounce only high-frequency input events such as keydown and input on text fields (300–500 ms is a reasonable window). Discrete interactions — checkbox toggles, radio button selections, button clicks — should write immediately without debouncing. The storage layer handles one write per user interaction without trouble; debouncing exists purely to avoid a write on every single keystroke in a text field.
Write eagerly throughout the session, not only on close. If every meaningful interaction triggers a storage write, a single failed write leaves the previous persisted value intact rather than losing all session state at once. The popup closing is not a safe flush point — the async write may not complete before the document is torn down by the browser. Structure your code so that chrome.storage.local.set is called synchronously in response to the user event, awaited properly inside an async handler, and any rejection is caught and at minimum logged.
Yes. The service worker can call chrome.runtime.sendMessage to send a message to the popup. In the popup, register a chrome.runtime.onMessage listener and apply the incoming data to the UI. This is complementary to the chrome.storage.onChanged listener described in Step 4 above — use messaging for imperative commands (“start loading spinner”, “auth token refreshed”) and onChanged for reactive data synchronisation. The full messaging pattern is covered in implementing background messaging between popup and service worker.
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.
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.
Build MV3-compliant browser extension popups: ephemeral lifecycle, storage hydration, service worker messaging, and cross-browser constraints explained.
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.