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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

A job reporting over a portThe side panel connects and sends start; the worker runs the job and posts throttled progress messages; the panel renders each; the job finishes and posts done; if the panel closes early, onDisconnect cancels the job.Side panelPortService workerconnect({name:'export'}) + startprogress 12%progress 47%progress 100% + done(if closed early) disconnectonDisconnect → abort job
The port carries progress out and carries cancellation back.

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.

Ports versus one-time messages for progressComparison of polling with sendMessage and pushing over a port on latency, worker wake-ups, cancellation signal and worker lifetime.AspectPolling sendMessagePortUpdate latencyPoll intervalImmediateMessagesOne per pollOne per updateKnows when UI closesNoonDisconnectKeeps worker aliveOnly while pollingWhile connected
Ports push, cancel and keep the worker alive; polling does none of these well.

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.

Progress that survives the UI closingA job that must continue without the UI persists its progress to storage; the port is only a live view; reopening the panel reads stored progress and reconnects to receive further updates.Jobwrites progressstorage.sessionjobProgressPort (if open)live updatespanel reopenedRead stored progressinstant renderconnect() againsame port nameResume live viewno restart
For jobs that should outlive the panel, storage is the record and the port is a view.

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; onDisconnect fires 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

  1. Start an export and confirm the progress bar advances smoothly with about ten updates per second in the worker’s console count.
  2. Close the panel mid-export (abort variant) and confirm the job stops at the next batch.
  3. Reload the extension mid-export (continue variant) and confirm the reopened panel shows stored progress and attaches.
  4. After completion, confirm the worker becomes idle in chrome://serviceworker-internals within 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.

Other Core APIs & Cross-Browser Data Management Resources