Using a SharedWorker for SSE Permalink to this section

Part of Sharing One SSE Connection Across Tabs, under Frontend Consumption & Client Patterns.

A SharedWorker is the natural owner for a shared Server-Sent Events stream: a single script instance per origin that every tab can connect to, which keeps running for as long as any tab is connected. This guide builds a production-ready worker: a registry of connected tabs, a stream that carries the union of what they need, resumption when the stream is reopened, detection of tabs that disappeared without saying goodbye, and the debugging workflow.

Symptom & Developer Intent Permalink to this section

  • The first version of the worker works, but after tabs are closed its port list grows forever and it keeps posting to dead ports.
  • Opening a new tab that needs an extra topic drops events for the others while the stream reopens.
  • Tabs from before and after a deploy talk to the same worker with different message formats.
  • It is unclear how to see the worker’s network traffic or logs.

The intent is a worker that holds exactly one stream, always carries what the open tabs need, never loses events when it reopens the stream, and cleans up after tabs however they leave.

Root Cause Analysis Permalink to this section

The MessagePort API has no close event. A tab that navigates away, crashes or is discarded by the browser simply stops listening; the worker is never told. pagehide handlers help for orderly departures but do not run when a tab crashes or is killed.

Life of a tab's connection to the worker State diagram of a tab's port moving from connected to subscribed, then to closed either through an orderly bye message or through missed heartbeats that mark it stale. Life of a tab's connection to the worker CONNECTED port registered SUBSCRIBED topics known STALE no ping 30 s REMOVED port dropped subscribe msg pings stop sweep bye on pagehide
Two paths to cleanup. The orderly one is fast; the heartbeat one catches everything the orderly one misses.

Reopening the stream for a new topic set is a real gap unless the new connection resumes from the last event the worker forwarded. And script versioning is a naming problem: a SharedWorker is identified by its script URL and name, so a new build with a new script URL gets a new worker, while tabs from the old build keep the old one — which is the behaviour you want, as long as the URL changes with each build.

Step-by-Step Resolution Permalink to this section

Step 1 — Version the worker by URL Permalink to this section

// Tab: the bundler emits a hashed filename, e.g. sse-worker.3f9c1a.js
const worker = new SharedWorker(new URL('./sse-worker.js', import.meta.url), { name: 'sse', type: 'module' });

Old tabs keep talking to the old worker; new tabs start a new one. Two streams exist briefly during a deploy, which is harmless, and the old worker exits when its last tab reloads.

Step 2 — Register ports with heartbeats Permalink to this section

// sse-worker.js
const clients = new Map();                     // port → { topics:Set, lastSeen:number }

self.onconnect = ({ ports: [port] }) => {
  clients.set(port, { topics: new Set(), lastSeen: Date.now() });
  port.onmessage = ({ data }) => {
    const c = clients.get(port);
    if (!c) return;
    c.lastSeen = Date.now();
    if (data.kind === 'subscribe') { c.topics = new Set(data.topics); scheduleReopen(); }
    if (data.kind === 'bye') { clients.delete(port); scheduleReopen(); }
  };
  port.start();
  for (const snap of snapshots.values()) port.postMessage(snap);      // current state first
};

setInterval(() => {                             // sweep tabs that vanished without 'bye'
  const cutoff = Date.now() - 30_000;
  let removed = false;
  for (const [port, c] of clients) if (c.lastSeen < cutoff) { clients.delete(port); removed = true; }
  if (removed) scheduleReopen();
  if (!clients.size) close();                   // nobody left: release the connection
}, 10_000);
// Tab: ping every 10 s, say goodbye on the way out.
setInterval(() => worker.port.postMessage({ kind: 'ping' }), 10_000);
addEventListener('pagehide', () => worker.port.postMessage({ kind: 'bye' }));

Background tabs have their timers throttled, often to once per minute. Set the sweep cutoff comfortably above the throttled interval, or tabs that are merely in the background will be dropped; 90 seconds is a safer cutoff where heavy throttling is expected.

Step 3 — Reopen for a new topic union without a gap Permalink to this section

let es = null, openTopics = '', lastId = '', reopenTimer = null;

function union() {
  return [...new Set([...clients.values()].flatMap((c) => [...c.topics]))].sort().join(',');
}

function scheduleReopen() {                     // debounce: several tabs subscribing at once → one reopen
  clearTimeout(reopenTimer);
  reopenTimer = setTimeout(() => {
    const topics = union();
    if (topics === openTopics && es) return;
    const next = new EventSource(`/api/stream?topics=${encodeURIComponent(topics)}&after=${encodeURIComponent(lastId)}`,
                                 { withCredentials: true });
    wire(next);
    next.onopen = () => { es?.close(); es = next; openTopics = topics; };   // swap only once the new one is live
  }, 150);
}

function wire(source) {
  for (const t of union().split(',').filter(Boolean)) source.addEventListener(t, (e) => {
    if (e.lastEventId) lastId = e.lastEventId;
    const msg = { kind: 'event', type: e.type, data: e.data, id: e.lastEventId };
    if (STATE_TYPES.has(e.type)) snapshots.set(e.type, msg);
    for (const port of clients.keys()) port.postMessage(msg);
  });
}

The new stream is opened before the old one is closed, starting after the last id forwarded, so events published during the switch arrive on one stream or the other. Duplicates at the overlap are filtered by id in the tabs, as described in deduplicating events on the client.

Swapping streams when the topic union changes Timeline of five seconds showing the old stream continuing until the new stream opens from the last forwarded id, after which the old one is closed. Swapping streams when the topic union changes Old stream New stream topics A, B topics A, B, C 0 1 2 3 4 5 seconds tab adds C old closed
Make-before-break. For a moment two streams overlap; ids let tabs discard the few events delivered on both.

Step 4 — Surface status to every tab Permalink to this section

Broadcast connection status (connecting, live, reconnecting, closed) to all ports, and send the current status to each new port on connect. Each tab’s UI indicator then reflects the shared stream, as in showing connection status in the UI.

Step 5 — Close the stream when the last tab leaves Permalink to this section

When the client map is empty, close the EventSource immediately rather than waiting for the browser to terminate the worker. That releases the server connection promptly and avoids a stream running with no one listening.

Validation & Monitoring Permalink to this section

Open chrome://inspect/#workers (or about:debugging#workers in Firefox) and inspect the shared worker: its console shows worker logs, and its Network panel shows the single stream with an EventStream tab. Then:

  1. Open three tabs; confirm one stream in the worker’s Network panel.
  2. Kill one tab from the browser’s task manager; within the sweep interval, the worker’s client count drops.
  3. Open a tab that needs a new topic; confirm the new stream opens before the old closes, and no events are missing in the other tabs.
Ports held by the worker after a day of normal use Bar chart comparing the number of ports the worker still holds at the end of a day with only pagehide cleanup and with pagehide plus heartbeat sweeping. Ports held by the worker after a day of normal use pagehide only 41 held pagehide + heartbeat sweep 3 held ports registered in the worker vs tabs actually open (3)
pagehide alone misses crashed, discarded and killed tabs. The sweep catches them.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Does a SharedWorker keep running after all tabs close?

No. The browser terminates it once no documents are connected. Close the stream yourself when the client list empties so the server sees the disconnect promptly.

Can the worker use cookies for the stream?

Yes. A same-origin EventSource in a SharedWorker sends the origin's cookies like one in a page. Token-based auth works through a fetch-based client in the worker, with tokens posted from a tab.

What if SharedWorker is not supported?

Fall back to leader election with Web Locks and BroadcastChannel, and finally to a stream per tab, behind the same interface.

How do I debug a SharedWorker?

Use chrome://inspect/#workers in Chromium browsers or about:debugging in Firefox to open DevTools for the worker, where its console and network activity are visible.