Running Work That Outlives the Popup
Keep tasks started from an MV3 popup alive after it closes: hand work to the service worker, persist a job record, report progress through storage, notify on completion, and avoid fetch calls dying with the popup.
Table of Contents
The user clicks “Export all” in the popup, then clicks back into the page to keep reading. The popup closes — and the export stops halfway, because the fetch calls and the loop driving them lived in the popup’s page, which the browser destroyed the moment it lost focus. Popups are the most ephemeral UI in an extension: they exist only while visible. Any work that should continue after the user looks away must not run there. This guide moves it to the service worker, makes it survive the worker’s own lifecycle, and keeps the user informed. It belongs to extension popup architecture.
Why popup work dies
A toolbar popup is a document whose lifetime is tied to its visibility. When it closes — the user clicks elsewhere, presses Escape, or switches windows — the browser tears down the document immediately: pending fetch requests are aborted, timers cleared, promises abandoned mid-chain, and beforeunload handlers are not given time to finish async work. Nothing errors, because nothing remains to observe the error. The service worker has a different, longer lifecycle: it runs as long as it has events or activity, up to its time limits, and can be woken again by alarms. Moving long work there, and making the work resumable through storage, gives it a lifetime independent of any UI.
Step-by-step: hand off, persist, report
1. Send intent, not work, from the popup
1// popup.js
2document.querySelector("#export").addEventListener("click", async () => {
3 const { jobId } = await chrome.runtime.sendMessage({ type: "export:start", format: "json" });
4 showProgress(jobId); // optional: only while the popup stays open
5});
Execution context: the popup. The popup tells the worker what to do and receives a job id; it does no network or heavy work itself. If the popup closes a millisecond later, the message has already been delivered and the worker owns the job.
2. Create a durable job record in the worker
1// sw.js
2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
3 if (msg?.type !== "export:start" || sender.tab) return;
4 startExport(msg.format).then((jobId) => sendResponse({ jobId }));
5 return true;
6});
7
8async function startExport(format) {
9 const jobId = crypto.randomUUID();
10 await chrome.storage.local.set({ [`job:${jobId}`]: { type: "export", format, state: "running", done: 0, total: null, startedAt: Date.now() } });
11 runExport(jobId); // not awaited: reply to the popup immediately
12 return jobId;
13}
Execution context: the service worker. The record is written before the work starts, so the job exists even if the worker is evicted immediately afterwards. Replying with the id before the work completes lets the popup show progress or close without waiting. Use chrome.storage.local if the job should survive a browser restart, session if not.
3. Make the job resumable
1async function runExport(jobId) {
2 const key = `job:${jobId}`;
3 let { [key]: job } = await chrome.storage.local.get(key);
4 const ids = await allItemIds();
5 job.total = ids.length;
6 for (let i = job.done; i < ids.length; i += 100) {
7 const batch = await loadItems(ids.slice(i, i + 100));
8 await appendToExport(jobId, batch);
9 job = { ...job, done: Math.min(i + 100, ids.length) };
10 await chrome.storage.local.set({ [key]: job }); // checkpoint after each batch
11 }
12 await finishExport(jobId);
13}
Execution context: the service worker. Each batch commits its progress, so if the worker is evicted mid-job, a restart can call runExport again and continue from job.done. Trigger resumption from runtime.onStartup and from a short alarm armed while a job is running. The full pattern is in persisting job progress across worker restarts.
4. Show progress if the popup is reopened
1// popup.js
2async function showProgress(jobId) {
3 const key = `job:${jobId}`;
4 const render = (job) => { bar.max = job.total ?? 1; bar.value = job.done; label.textContent = job.state; };
5 render((await chrome.storage.local.get(key))[key]);
6 chrome.storage.onChanged.addListener((c, area) => { if (area === "local" && c[key]) render(c[key].newValue); });
7}
8
9// on popup open: find running jobs
10const all = await chrome.storage.local.get(null);
11for (const [k, job] of Object.entries(all)) if (k.startsWith("job:") && job.state === "running") showProgress(k.slice(4));
Execution context: the popup. Because progress lives in storage, a popup opened later picks up the current state instantly and updates live. No messaging protocol is needed between the worker and however many popups, side panels or options pages are watching.
5. Tell the user when it is done
1async function finishExport(jobId) {
2 const key = `job:${jobId}`;
3 await chrome.storage.local.set({ [key]: { ...(await chrome.storage.local.get(key))[key], state: "done", finishedAt: Date.now() } });
4 await chrome.action.setBadgeText({ text: "✓" });
5 chrome.notifications?.create(`job-${jobId}`, {
6 type: "basic", iconUrl: "icons/128.png",
7 title: "Export finished", message: "Your export is ready. Click to download.",
8 });
9}
Execution context: the service worker. The user who closed the popup needs a signal that does not depend on reopening it: a badge change is unobtrusive, a notification is explicit (and needs the notifications permission). Clicking the notification can open the result. Clear the badge when the popup next opens.
6. Do not keep the popup open artificially
Some extensions try to prevent the popup from closing — refocusing it, opening an alert — or move long work into an <iframe> inside it. These do not work reliably and fight the browser’s model. If the user needs to watch the work continuously, open a side panel or a separate extension window; see choosing between popup, side panel and tab.
7. Clean up old job records
1chrome.alarms.create("jobs-gc", { periodInMinutes: 24 * 60 });
2chrome.alarms.onAlarm.addListener(async ({ name }) => {
3 if (name !== "jobs-gc") return;
4 const all = await chrome.storage.local.get(null);
5 const stale = Object.entries(all).filter(([k, j]) => k.startsWith("job:") && j.finishedAt && Date.now() - j.finishedAt > 7 * 86_400_000);
6 await chrome.storage.local.remove(stale.map(([k]) => k));
7});
Execution context: the service worker. Job records accumulate; a daily sweep keeps storage tidy while retaining recent history for the popup to display.
Common mistakes
- Running fetch loops in the popup. They are aborted when it closes.
- Awaiting the whole job before replying. The popup may close before the reply arrives; reply with a job id first.
- Job state only in worker memory. Eviction loses it; persist after each batch.
- No completion signal. Users never learn the job finished.
- Fighting the popup’s lifecycle. Use a side panel or window for watched work.
Cross-browser variation
- Chrome / Edge: popup closes on blur; the worker continues with its own lifetime limits.
- Firefox: same popup behaviour; the event page background is more forgiving about long jobs, which can hide missing checkpoints — test with forced restarts.
- Safari: popups close on blur as well; background suspension is aggressive, so resumable jobs and startup resumption matter most here.
Verification
- Start an export and immediately click away; confirm the job record advances in storage and completes.
- Stop the worker mid-job from
chrome://serviceworker-internals; confirm it resumes from the last checkpoint. - Reopen the popup during the job; confirm it shows current progress.
- Confirm a notification or badge appears on completion.
FAQ
Can I use navigator.sendBeacon from the popup to finish a request?
It can deliver a final small POST as the page unloads, but it cannot wait for a response. Use it only for fire-and-forget telemetry.
How long can the worker run a job?
Each event’s work should complete within minutes; long jobs should be batched across events, with alarms or ports keeping things moving.
Should the popup cancel the job if the user clicks Cancel?
Yes — send a cancel message; the worker sets the job’s state and the batch loop checks it before each batch.
Related
- Why the popup closes and how to work with it — the lifecycle behind this guide.
- Streaming progress updates over a port — live progress while the UI is open.
- Progress notifications for long tasks — telling the user without UI open.
- Extension popup architecture — the parent topic.