Streaming Progress Updates over a Port
Report progress from a long MV3 service worker job to a popup or side panel with runtime.connect: port lifecycle, throttled progress messages, cancellation on disconnect, reconnecting and resuming from stored state.
Table of Contents
The side panel starts an export that takes forty seconds, and the user should see a progress bar, not a frozen spinner. One-time messages are a poor fit: the panel would have to poll, and each poll is a round trip that may wake a sleeping worker. A long-lived port — chrome.runtime.connect — gives the worker a channel to push updates for as long as the panel is open, tells the worker immediately when the panel closes, and keeps the worker alive while it is connected. Done carelessly, it floods the UI with thousands of messages or leaves orphaned jobs running after the user has gone. This guide builds a progress channel that behaves. It belongs to message passing architecture.
How a port differs from one-time messages
chrome.runtime.connect({ name }) from an extension page opens a port to the service worker, which receives it in chrome.runtime.onConnect. Either side can then postMessage any number of times, and both get onDisconnect when the other end goes away — the page closing, the worker being terminated, or an explicit disconnect(). Two properties make ports right for progress. First, the worker can push without being asked, so updates arrive as soon as they happen. Second, an open port from an extension page keeps the service worker alive in Chrome, so a job reporting progress to a visible panel is not evicted mid-way. The flip side is that the port tells you when nobody is watching any more — which is when a cancellable job should stop.
Step-by-step: a well-behaved progress channel
1. Open the port from the UI and render messages
1// sidepanel.js
2const port = chrome.runtime.connect({ name: "export" });
3port.onMessage.addListener((m) => {
4 if (m.type === "progress") { bar.value = m.done; bar.max = m.total; label.textContent = m.stage; }
5 if (m.type === "done") { label.textContent = `Exported ${m.count} items`; }
6 if (m.type === "error") { label.textContent = `Export failed: ${m.message}`; }
7});
8port.onDisconnect.addListener(() => { if (!finished) label.textContent = "Connection lost — reopen to see progress"; });
9port.postMessage({ type: "start", format: "json" });
Execution context: the side panel (or popup, or options page). The port name routes the connection to the right handler in the worker. A <progress> element gives accessible progress semantics for free. Handling onDisconnect on the UI side covers the worker being terminated or reloaded.
2. Accept the connection and run the job
1// sw.js
2chrome.runtime.onConnect.addListener((port) => {
3 if (port.name !== "export") return;
4 const ctrl = new AbortController();
5 port.onDisconnect.addListener(() => ctrl.abort());
6 port.onMessage.addListener(async (m) => {
7 if (m.type !== "start") return;
8 try {
9 const count = await runExport(m.format, ctrl.signal, progressSender(port));
10 safePost(port, { type: "done", count });
11 } catch (err) {
12 if (err.name !== "AbortError") safePost(port, { type: "error", message: String(err.message ?? err) });
13 }
14 });
15});
16
17function safePost(port, msg) {
18 try { port.postMessage(msg); } catch { /* port already closed */ }
19}
Execution context: the service worker, with the listener at the top level. Tying an AbortController to onDisconnect makes cancellation automatic: when the user closes the panel, the job stops at its next check of the signal. Posting to a closed port throws, so wrap it. Validate m in production as with any message.
3. Throttle progress messages
1function progressSender(port, intervalMs = 100) {
2 let last = 0, pending = null, timer = null;
3 const flush = () => { timer = null; if (pending) { safePost(port, pending); pending = null; last = Date.now(); } };
4 return (done, total, stage) => {
5 pending = { type: "progress", done, total, stage };
6 const wait = intervalMs - (Date.now() - last);
7 if (wait <= 0) flush();
8 else timer ??= setTimeout(flush, wait);
9 };
10}
Execution context: the service worker. A job processing 50,000 items would otherwise post 50,000 messages, each structured-cloned and dispatched to the UI, which spends more time rendering progress than the job spends working. Sending at most ten updates a second, always including the latest values, keeps the bar smooth and the overhead negligible. The final done message is sent unthrottled.
4. Check for cancellation inside the job
1async function runExport(format, signal, report) {
2 const ids = await allItemIds();
3 const out = [];
4 for (let i = 0; i < ids.length; i += 200) {
5 signal.throwIfAborted();
6 out.push(...(await loadItems(ids.slice(i, i + 200))));
7 report(Math.min(i + 200, ids.length), ids.length, "Collecting items");
8 }
9 signal.throwIfAborted();
10 report(ids.length, ids.length, "Writing file");
11 await saveExport(format, out);
12 return out.length;
13}
Execution context: the service worker. Checking the signal at batch boundaries makes cancellation prompt without per-item overhead. throwIfAborted raises an AbortError, which step 2 treats as a quiet stop rather than a failure. Whether a cancelled job should leave partial results depends on the feature; an export should not.
5. Decide whether the job should outlive the UI
Some jobs should stop when the user closes the panel (a preview render); others should continue (an export the user is waiting for). For the second kind, do not abort on disconnect — persist progress to chrome.storage.session instead, and let a reopened panel read it and reconnect.
1port.onDisconnect.addListener(() => { /* do not abort: job continues */ });
2const report = async (done, total, stage) => {
3 await chrome.storage.session.set({ exportProgress: { done, total, stage, at: Date.now() } });
4 for (const p of viewers) safePost(p, { type: "progress", done, total, stage });
5};
Execution context: the service worker. A job that outlives the UI loses the keep-alive the port provided, so it must be resumable across worker restarts, as in persisting job progress across worker restarts. Track connected viewers in a set and broadcast to all of them, so two open windows both show progress.
6. Reconnect after the worker restarts
1// sidepanel.js
2function connectWithRetry(attempt = 0) {
3 const port = chrome.runtime.connect({ name: "export" });
4 port.onDisconnect.addListener(() => {
5 if (!finished && attempt < 5) setTimeout(() => connectWithRetry(attempt + 1), 500 * 2 ** attempt);
6 });
7 port.postMessage({ type: "attach" }); // join a running job rather than start a new one
8 return port;
9}
Execution context: the side panel. If the extension is reloaded or the worker crashes, the port disconnects. A short reconnect loop with backoff, sending “attach” rather than “start”, lets the UI recover without starting a duplicate job. The worker answers “attach” with the current stored progress.
7. Close the port when finished
1port.onMessage.addListener((m) => {
2 if (m.type === "done" || m.type === "error") { finished = true; port.disconnect(); }
3});
Execution context: the UI. Leaving ports open after a job completes keeps the service worker alive needlessly. Disconnecting when the work is done lets the worker go idle and return its resources.
Common mistakes
- Posting every item as a progress message. Throttle to a few updates per second.
- No cancellation on disconnect. Jobs keep running for users who have walked away.
- Posting to a closed port without a guard. It throws inside your job and turns success into failure.
- Restarting the job on reconnect. Attach to the running job instead.
- Leaving ports open. The worker never sleeps.
Cross-browser variation
- Chrome / Edge: open ports from extension pages keep the service worker alive;
onDisconnectfires on page close and worker termination. - Firefox: ports work identically; Firefox’s event page also stays alive while ports are open.
- Safari: ports work, but the background may still be suspended under memory pressure; persist progress and support reconnecting.
Verification
- Start an export and confirm the progress bar advances smoothly with about ten updates per second in the worker’s console count.
- Close the panel mid-export (abort variant) and confirm the job stops at the next batch.
- Reload the extension mid-export (continue variant) and confirm the reopened panel shows stored progress and attaches.
- After completion, confirm the worker becomes idle in
chrome://serviceworker-internalswithin about thirty seconds.
FAQ
Can content scripts open ports to the worker?
Yes, with chrome.runtime.connect from the content script. Note that ports from content scripts do not keep the worker alive the same way in every version; persist state accordingly.
How large can each progress message be?
Keep them tiny — numbers and a short label. Large payloads belong in storage, with the message pointing to them.
Should I use the same port for several jobs?
One port per job keeps cancellation simple. If you multiplex, include a job id in every message.
Related
- Long-lived ports vs one-time messages — choosing between the two.
- Streaming responses with fetch in the service worker — the same channel for streamed answers.
- Progress notifications for long tasks — progress when no UI is open.
- Message passing architecture — the parent topic.