Why the Popup Closes and How to Work With It
The MV3 popup is destroyed on every blur — what that breaks, which interactions are impossible inside it, and the patterns that make a disposable surface feel continuous.
Table of Contents
The popup is not a window. It is a renderer the browser creates when the toolbar button is clicked and destroys the moment focus moves anywhere else — another tab, the address bar, a file picker, DevTools. Every variable, every open connection and every half-filled form goes with it. Most popup bugs are a developer expecting it to behave like a page. This guide is part of extension popup architecture.
What destroys the popup
Step-by-step
1. Never hold state only in the popup
The popup’s JavaScript heap is temporary storage with a lifetime measured in seconds. Anything the user typed, selected or started belongs in storage before the next tick.
1const draft = document.querySelector("#note");
2
3draft.addEventListener("input", () => {
4 chrome.storage.session.set({ noteDraft: draft.value }); // fire and forget
5});
6
7// On open, restore whatever was there.
8const { noteDraft = "" } = await chrome.storage.session.get("noteDraft");
9draft.value = noteDraft;
Execution context: the popup document, which has its own renderer and its own chrome.* bindings. storage.session is the right area for a draft: it survives the popup closing and does not persist to disk across a browser restart.
2. Flush on pagehide, not on beforeunload
beforeunload is unreliable in an extension popup. pagehide and visibilitychange fire dependably and are the last moment anything can be saved.
1addEventListener("pagehide", () => {
2 chrome.storage.session.set({ noteDraft: draft.value });
3});
Execution context: the popup document. The browser does not wait for the returned promise, so this must be a single call — a chain of awaits will be cut off mid-way.
3. Do not start long work in the popup
Work started in the popup dies with it. Hand it to the service worker and let the popup render progress from storage.
1// popup.js — ask, do not do
2document.querySelector("#export").addEventListener("click", async () => {
3 await chrome.runtime.sendMessage({ type: "export:start" });
4 status.textContent = "Export started — you can close this.";
5});
Execution context: the popup. The message returns as soon as the worker acknowledges; the worker owns the job and writes progress to storage, where the popup reads it on the next open. The pattern is the same one used in chaining alarms for long-running jobs.
4. Know which interactions are impossible
Several ordinary web interactions cannot be completed inside a popup because they move focus:
- A file
<input>opens a system picker; on most platforms the popup closes and thechangeevent never arrives. window.openor a target=_blank link opens a tab, and the popup closes before any follow-up code runs.- An OAuth flow launched from the popup will lose the popup; run it from the worker so the result survives.
- A confirm dialog is not available —
window.confirmis blocked in extension pages.
The workaround for all four is the same: move the interaction to an extension page in a tab, which the user can leave and return to.
1document.querySelector("#import").addEventListener("click", () => {
2 chrome.tabs.create({ url: chrome.runtime.getURL("pages/import.html") });
3});
Execution context: the popup. The popup closes immediately after this call, which is fine — the tab it opened is the surface the user continues in, as described in opening and tracking extension pages in tabs.
5. Make reopening feel like resuming
Because the popup is destroyed rather than hidden, “reopen” is the only continuity the user gets. Restore scroll position, the selected tab and any in-progress state so the second open looks like the first never ended.
1const { uiState = {} } = await chrome.storage.session.get("uiState");
2selectTab(uiState.tab ?? "recent");
3list.scrollTop = uiState.scrollTop ?? 0;
4
5addEventListener("pagehide", () => {
6 chrome.storage.session.set({ uiState: { tab: currentTab, scrollTop: list.scrollTop } });
7});
Execution context: the popup document. Reading from storage.session before the first paint is what makes this invisible — a restore that happens after render produces a visible jump, which is the problem addressed in loading popup data without a flash of empty UI.
When the popup is the wrong surface
If the user needs to refer to the page while using your UI, the popup is structurally wrong: looking at the page closes it. Three alternatives, in rough order of preference:
- A side panel, which persists across navigations — Chrome only, covered in side panel support across browsers.
- Injected page UI in a shadow root, which lives as long as the tab does.
- An extension page in a tab, which the user can leave and return to.
The popup’s strength is that it is instant, scoped to a single interaction, and requires no cleanup. Use it for a switch, a status readout, or a one-click action — not for anything the user will spend a minute in.
Designing for a surface measured in seconds
Median popup sessions are short — most users open, glance, and close within a few seconds. That number should shape the design more than any technical constraint does.
One decision per open. A popup that asks the user to configure three things is a popup they will close halfway through. Put the single most likely action at the top, in the largest control, and move the rest to the options page.
No modes. A popup with tabs or a wizard assumes continuity it does not have. If the user switches tab to check something, they come back to step one.
State the state. Because the popup is the only place many users see the extension, it should say plainly what the extension is doing right now — syncing, blocked here, nothing to do — rather than presenting only controls.
1// A status line that is always populated, never empty.
2function statusText(s) {
3 if (s.syncing) return "Syncing…";
4 if (!s.enabledHere) return "Not active on this site";
5 if (s.blocked) return `${s.blocked} requests blocked here`;
6 return "Active — nothing to report";
7}
Execution context: the popup document. Every branch returns a sentence: an empty status area reads as a loading failure, and “nothing to report” is information.
Make the close obvious. Users close a popup by clicking away, which is fine — but an action that completes should say so before the popup disappears, or it will be repeated. A brief inline confirmation beats closing immediately on success.
The corollary is that anything the user might want to read belongs somewhere else. A changelog, an error log, a list of thirty items: those are an options page or a side panel. The popup’s job is the next single thing.
Cross-browser variation
- Chrome / Edge: the popup is destroyed on blur with no event other than
pagehide. A file picker closes it on every platform. DevTools attached to the popup pins it, which is why the behaviour looks different during development. - Firefox: the same destruction model. Firefox is slightly more forgiving with file pickers on some platforms, but relying on that produces a Chrome bug.
- Safari: destruction is aggressive and can happen fractionally earlier, so a
pagehidehandler that does more than one thing may not complete. Keep the final flush to a single call. - All three:
chrome.storagewrites issued from the popup complete even after the document is gone — the call is already in the browser process. That is what makes fire-and-forget persistence safe here.
Verification
- Open the popup, type into a field, click the page, and reopen. The text should still be there.
- Confirm nothing is lost when focus moves fast:
1// popup console
2await chrome.storage.session.get("noteDraft");
3// { noteDraft: "half-typed note" }
Execution context: the popup’s DevTools console. Open it via right-click → Inspect popup, which also pins the popup — so close DevTools before testing the blur behaviour itself.
- Start an export from the popup, close it immediately, and confirm the export still completes.
- Confirm a file input inside the popup behaves as documented on your target platforms, and that the fallback tab is offered where it does not.
FAQ
Can I stop the popup from closing?
No. There is no API to keep it open and no supported workaround. Design for destruction instead.
Does the popup have its own service worker?
No — it shares the extension’s single service worker, and that worker may be asleep when the popup opens. Render from storage rather than waiting for a message round trip.
Why does window.close() sometimes do nothing?
Because the popup is already closing for another reason, or because the call happened after a focus change. It is only reliable as the last statement of a user-initiated handler.
Related
- Loading popup data without a flash of empty UI — rendering restored state before the first frame.
- Managing extension state across reloads — where popup state should live.
- Preserving popup state when it closes — the UI-side treatment of the same constraint.
- Extension popup architecture — the parent guide.