Preserving Popup State When It Closes
Keep MV3 popup state across open and close cycles: why unload handlers are unreliable, writing on change, restoring scroll and form values, and what belongs in session versus local storage.
Table of Contents
- Root cause: the popup is torn down without warning
- Step 1. Write on change, debounced
- Step 2. Flush on the signals that do arrive
- Step 3. Restore everything, including scroll and focus
- Step 4. Do not restore a draft the user has finished with
- Step 5. Keep the restore off the critical path
- Cross-browser variation
- Verification
- FAQ
- Related
The user types half a note in the popup, clicks the page to check something, and comes back to an empty field. The popup is destroyed on blur — not paused, not hidden, destroyed — and it gets no reliable notice beforehand. Anything that should survive has to be written while the user is still interacting. This page is part of Popup Interface Design and covers state that comes back exactly as the user left it.
Root cause: the popup is torn down without warning
Closing a popup destroys its document. beforeunload does not fire in a way you can depend on, unload may not run to completion, and even where a handler starts, an asynchronous write inside it is not guaranteed to finish — the renderer can be gone before the storage call lands. The only reliable moment to persist is while the popup is demonstrably alive.
Step 1. Write on change, debounced
Every control writes its own value as it changes. Debouncing keeps typing from producing a write per keystroke without moving the write to a moment that might not arrive.
1// popup.js
2const DRAFT_KEY = "popup:draft";
3let writeTimer = null;
4
5function scheduleWrite(patch) {
6 clearTimeout(writeTimer);
7 writeTimer = setTimeout(async () => {
8 const { [DRAFT_KEY]: draft = {} } = await chrome.storage.session.get(DRAFT_KEY);
9 await chrome.storage.session.set({ [DRAFT_KEY]: { ...draft, ...patch } });
10 }, 250);
11}
12
13document.querySelector("#note").addEventListener("input", (event) => {
14 scheduleWrite({ note: event.target.value });
15});
16document.querySelector("#category").addEventListener("change", (event) => {
17 scheduleWrite({ category: event.target.value });
18});
Execution context: Popup renderer. A 250 ms debounce is short enough that a user cannot realistically close the popup inside the window while still typing, and long enough to collapse a sentence into one write. Drafts belong in storage.session: they should survive the popup closing and reopening, and they should not persist to disk or follow the user to another device.
Step 2. Flush on the signals that do arrive
visibilitychange and blur are not guaranteed to complete an async write, but they cost nothing and sometimes win the race. Use them as a best-effort flush on top of the reliable path, never as the only path.
1// popup.js
2function flushNow() {
3 clearTimeout(writeTimer);
4 // Fire and forget: if the renderer survives long enough, the write lands.
5 void chrome.storage.session.set({ [DRAFT_KEY]: collectState() });
6}
7
8document.addEventListener("visibilitychange", () => {
9 if (document.visibilityState === "hidden") flushNow();
10});
11window.addEventListener("pagehide", flushNow);
Execution context: Popup renderer. pagehide is more reliable than unload in modern engines and is what a bfcache-aware page would use anyway. Neither is awaited, because there is nothing useful to do with the result — the debounced write in step 1 is what actually guarantees the data is there.
Step 3. Restore everything, including scroll and focus
Restoring the values but not the position still feels like losing your place. Capture the scroll offset and the focused element alongside the values.
1// popup.js
2async function hydrate() {
3 const { [DRAFT_KEY]: draft } = await chrome.storage.session.get(DRAFT_KEY);
4 if (!draft) return;
5
6 document.querySelector("#note").value = draft.note ?? "";
7 document.querySelector("#category").value = draft.category ?? "all";
8
9 const list = document.querySelector("#list");
10 if (typeof draft.scrollTop === "number") list.scrollTop = draft.scrollTop;
11
12 // Restore focus and caret so the user resumes exactly where they were.
13 if (draft.focusId) {
14 const el = document.getElementById(draft.focusId);
15 el?.focus();
16 if (el && typeof draft.caret === "number" && "setSelectionRange" in el) {
17 el.setSelectionRange(draft.caret, draft.caret);
18 }
19 }
20}
21
22document.addEventListener("DOMContentLoaded", () => { void hydrate(); });
Execution context: Popup renderer. Restoring the caret position is the detail that makes the difference between “my text is still here” and “nothing happened” — without it the caret lands at the start of the field and the next keystroke lands in the wrong place. Capturing scrollTop needs a scroll listener that feeds the same debounced writer, which is why the writer takes a patch rather than the whole state.
Step 4. Do not restore a draft the user has finished with
A draft that reappears after the user submitted it reads as a bug. Clear it explicitly on the actions that complete the interaction.
1// popup.js
2document.querySelector("#save").addEventListener("click", async () => {
3 const state = collectState();
4 await chrome.runtime.sendMessage({ type: "SAVE_NOTE", note: state.note });
5 await chrome.storage.session.remove(DRAFT_KEY); // the draft is finished
6 window.close();
7});
8
9document.querySelector("#discard").addEventListener("click", async () => {
10 await chrome.storage.session.remove(DRAFT_KEY);
11 window.close();
12});
Execution context: Popup renderer. Removing the draft before closing rather than after is necessary — after window.close() there is no renderer left to run anything. Awaiting the message before clearing means a failed save leaves the draft intact, which is the behaviour a user expects when something goes wrong.
Step 5. Keep the restore off the critical path
Hydration is a storage read, and a popup that renders an empty form and then fills it produces a visible flash. Render the skeleton at final size, then populate.
1/* popup.css — reserve the space before the data arrives */
2#list { min-height: 320px; overflow-y: auto; }
3#note { min-height: 88px; }
4body { width: 360px; }
Execution context: Popup stylesheet, applied before the first paint. Fixing the dimensions in CSS means the storage read fills a layout that already exists rather than causing the popup window itself to resize — and since a popup is sized by its content and cannot be resized afterwards, that resize would be permanent for the life of the popup.
Collecting the state in one place keeps the writer, the flush and the hydration in agreement about what “state” means:
1// popup.js — one definition of the popup's restorable state
2function collectState() {
3 const active = document.activeElement;
4 return {
5 note: document.querySelector("#note").value,
6 category: document.querySelector("#category").value,
7 scrollTop: document.querySelector("#list").scrollTop,
8 focusId: active?.id || null,
9 caret: active && "selectionStart" in active ? active.selectionStart : null,
10 };
11}
Execution context: Popup renderer. Reading document.activeElement at collection time rather than tracking focus events keeps the function pure and cheap enough to call on every debounce tick. The selectionStart guard matters because activeElement may be a button or the body, neither of which has a caret — and reading the property off an element that does not have it is how a save handler ends up throwing on the one path you cannot easily test.
Cross-browser variation
- Chrome 120+: popups are destroyed on blur.
storage.sessionis available and is the right home for drafts. - Firefox 121+: the same teardown behaviour;
browser.storage.sessionis supported. Firefox’s popup can be torn down slightly later, which can mask a missing debounced write. - Safari 17+: the same destruction on blur, with
storage.sessionavailable from Safari 16.4. Treat the unload flush as even less reliable here. - All engines: no engine guarantees an async write started during teardown will complete. The debounced write during interaction is the only portable guarantee.
Verification
- Type into the field, click the page to close the popup, and reopen it. The text, the caret position and the scroll offset should all be as you left them.
- Type and close within 100 ms — faster than the debounce. The
pagehideflush should usually catch it; if it does not, shorten the debounce rather than relying on the flush. - Save, then reopen. The draft must be gone.
- Restart the browser and reopen the popup. The draft should be gone and the settings should remain, confirming the two are in different areas.
FAQ
Why does beforeunload not work in a popup?
The popup is not navigating, it is being destroyed, and the engines do not guarantee the handler runs to completion. Even where it fires, an async storage write inside it races the teardown.
Should drafts go in session or local storage?
Session, in almost every case. A draft should survive the popup closing and should not outlive the browser session or sync to another device — which is exactly what session storage provides.
How short should the debounce be?
Long enough to collapse typing, short enough that a user cannot close the popup inside it. Between 150 and 300 ms works well; below that you are paying writes for no benefit.
Can I keep state in the service worker instead?
You can, but it does not help: the worker is evicted on its own schedule, so its memory is no more durable than the popup’s. Storage is the shared, durable place both contexts can rely on.
Related
- Dark mode in extension popups — another thing to restore before first paint.
- Fixing popup size and overflow issues — why the skeleton must be full size.
- Managing extension state across reloads — the same problem from the worker’s side.
- Popup Interface Design — the guide this page belongs to.