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.

Published July 25, 2026 Updated July 25, 2026 8 min read
Table of Contents

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.

Where a save can and cannot happenWrites during interaction are reliable; a write started in an unload handler races the renderer teardown and often loses.popup opensdocument destroyedHydrateread stored …User interactsreliable write windowBlurteardown beg…Unload hand…may not comp…write here, on every changean async write started here often loses
Everything to the right of the blur marker is unreliable — so the save has to happen to the left of it.

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.

The three kinds of popup state and where each belongsDrafts and view state go to session storage, settings to sync, and derived data is recomputed rather than stored.Draft and view statenote text, scroll, tabSettingsthe user's choicesDerived datacounts, filtered listseach has exactly one correct destinationstorage.sessionsurvives close, not restartstorage.syncfollows the userRecompute on opennever stored
Sorting state by these three buckets first makes every subsequent decision mechanical.

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.

First paint against the storage readThe skeleton paints at final size immediately, the session read resolves a few milliseconds later, and the values fill a layout that already exists.Popup documentstorage.sessionWindow framefirst paint at 360 × 320CSS min-heightwindow size fix…get(DRAFT_KEY)draft valuespopulate — no reflow
The popup window is measured once, at first paint — so the skeleton has to be the final size, not the empty one.
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.session is available and is the right home for drafts.
  • Firefox 121+: the same teardown behaviour; browser.storage.session is 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.session available 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

  1. 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.
  2. Type and close within 100 ms — faster than the debounce. The pagehide flush should usually catch it; if it does not, shorten the debounce rather than relying on the flush.
  3. Save, then reopen. The draft must be gone.
  4. 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.

Other UI/UX Patterns & Interactive Components Resources