Sharing One SSE Connection Across Tabs Permalink to this section

Part of Frontend Consumption & Client Patterns.

Power users open a web application in many tabs: a dashboard here, three documents there, a settings page left open since Tuesday. If every tab opens its own Server-Sent Events stream, one user becomes eight connections. On HTTP/1.1 that runs straight into the browser’s limit of six connections per origin — the seventh tab hangs, and so do ordinary API requests from every tab, because they queue behind the streams. Even on HTTP/2, eight streams per user multiplies server connections, fan-out work and bandwidth for no benefit, since every tab receives the same events. The fix is to hold one stream per browser profile and share its events with every tab. This guide covers the two browser mechanisms that make that possible — a SharedWorker that owns the stream, and leader election over BroadcastChannel where one tab owns it — how to handle the awkward lifecycle questions each raises, and how to fall back where neither is available.

How It Works Permalink to this section

Both designs have the same shape: exactly one execution context holds the EventSource, and every tab receives events from it over a same-origin messaging channel. They differ in which context holds the stream.

Two ways to share one stream Two panels comparing a SharedWorker that owns the stream with a leader tab elected over BroadcastChannel, listing how each works and its trade-offs. Two ways to share one stream SharedWorker owns the stream one worker per origin + URL tabs connect via MessagePort survives any tab closing not available everywhere Leader tab owns the stream tabs elect one leader leader relays on BroadcastChannel hand-over when leader closes works wherever BroadcastChannel does
A SharedWorker is the cleaner owner because it outlives any single tab. Leader election works everywhere BroadcastChannel does, at the cost of handing over when the leader closes.

In the SharedWorker design, the first tab to create new SharedWorker('/sse-worker.js') starts the worker; later tabs connect to the same instance. The worker opens the stream and forwards each event to every connected port. The worker lives as long as at least one tab is connected, so closing any individual tab does not interrupt the stream.

In the leader election design, tabs coordinate over a BroadcastChannel (or the Web Locks API) so that exactly one is the leader. The leader opens the stream and rebroadcasts events on the channel; followers listen. When the leader tab closes, the others notice and elect a new leader, which opens a new stream and resumes from the last event id it saw.

Without sharing                       With sharing
tab 1 ── stream ── server             tab 1 ─┐
tab 2 ── stream ── server             tab 2 ─┼─ owner ── one stream ── server
tab 3 ── stream ── server             tab 3 ─┘

Server-Side Implementation Permalink to this section

The server needs no special support: it sees one ordinary SSE connection per browser. Two server behaviours make sharing work better:

  • Resumable ids. Leader hand-over and worker restarts reconnect with Last-Event-ID; the server must replay after it, as in implementing a replay buffer for Last-Event-ID.
  • Multiplexed event types. One shared stream carries everything every tab might need. Name events by feature (notification, doc:42:op, presence) so each tab filters to what it displays, or let tabs tell the owner which topics they need and have the owner open a stream for the union.
// One stream per session carries every feature's events, each with its own name.
app.get('/api/stream', requireSession, async (req, res) => {
  openStream(res);
  const topics = parseTopics(req.query.topics);            // union requested by the owner
  const sub = await bus.subscribe(topicsFor(req.user, topics), (e) =>
    res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`));
  req.on('close', () => sub.unsubscribe());
});

A per-user connection limit on the server — covered in limiting SSE connections per user — becomes a useful safety net: with sharing in place, a user legitimately needs one or two connections (one per browser or device), so a low limit catches bugs where sharing broke.

Client-Side Consumption Permalink to this section

The SharedWorker version Permalink to this section

// sse-worker.js — one instance per origin, shared by every tab.
const ports = new Set();
let es = null;
let topics = new Map();                        // port → Set<topic>

function union() { return [...new Set([...topics.values()].flatMap((s) => [...s]))].sort(); }

function reopen() {
  const lastId = es?.lastEventId;              // not a standard property: tracked below
  es?.close();
  const url = `/api/stream?topics=${encodeURIComponent(union().join(','))}`;
  es = new EventSource(url, { withCredentials: true });
  es.onmessage = forward;
  for (const t of union()) es.addEventListener(t, forward);
  es.onopen = () => broadcast({ kind: 'status', status: 'live' });
  es.onerror = () => broadcast({ kind: 'status', status: es.readyState === 2 ? 'closed' : 'reconnecting' });
}

function forward(e) { broadcast({ kind: 'event', type: e.type, data: e.data, id: e.lastEventId }); }
function broadcast(msg) { for (const p of ports) p.postMessage(msg); }

self.onconnect = (ev) => {
  const port = ev.ports[0];
  ports.add(port);
  port.onmessage = ({ data }) => {
    if (data.kind === 'subscribe') { topics.set(port, new Set(data.topics)); reopen(); }
    if (data.kind === 'bye') { ports.delete(port); topics.delete(port); if (!ports.size) es?.close(); }
  };
  port.start();
};
// In each tab
const worker = new SharedWorker('/sse-worker.js', { name: 'sse' });
worker.port.onmessage = ({ data }) => data.kind === 'event' ? dispatch(data) : setStatus(data.status);
worker.port.postMessage({ kind: 'subscribe', topics: ['notification', 'presence'] });
window.addEventListener('pagehide', () => worker.port.postMessage({ kind: 'bye' }));

Reopening the stream whenever the topic union changes keeps one connection that carries exactly what the open tabs need. Because EventSource does not let you set Last-Event-ID on a new instance, the worker tracks the last id it forwarded and passes it as a query parameter when reopening, which the server treats like the header. Using a SharedWorker for SSE completes the implementation, including port cleanup when tabs crash without saying goodbye.

Three tabs, one SharedWorker, one stream Sequence diagram of three tabs connecting to one SharedWorker, the worker opening a single EventSource, and an event from the server being forwarded to all three tabs. Three tabs, one SharedWorker, one stream Tab A Tab B SharedWorker Server connect, subscribe GET /api/stream (one connection) connect, subscribe event: notification postMessage postMessage
The server sees one connection. Each tab receives the event from the worker over its own MessagePort.

The leader-election version Permalink to this section

The Web Locks API gives a simple, robust election: every tab requests the same named lock, the browser grants it to one tab at a time, and when that tab closes the lock passes to the next waiter.

const channel = new BroadcastChannel('sse');
let es = null;

navigator.locks.request('sse-leader', async () => {
  // Only the leader reaches this point; the promise below holds the lock until the tab closes.
  const lastId = sessionStorage.getItem('sse-last-id') ?? '';
  es = new EventSource(`/api/stream?after=${encodeURIComponent(lastId)}`, { withCredentials: true });
  es.onmessage = relay;
  ['notification', 'presence'].forEach((t) => es.addEventListener(t, relay));
  await new Promise(() => {});                           // never resolves: keep leadership
});

function relay(e) {
  const msg = { type: e.type, data: e.data, id: e.lastEventId };
  dispatch(msg);                                         // the leader's own UI
  channel.postMessage(msg);                              // every other tab
}

channel.onmessage = ({ data }) => {                      // followers
  if (data.id) sessionStorage.setItem('sse-last-id', data.id);
  dispatch(data);
};

When the leader tab closes, the browser releases its lock and grants it to a waiting tab, which opens a new stream from the last id it recorded. Leader election with BroadcastChannel for SSE covers the details, including storing the last id where every tab can read it and handling a leader tab that is frozen in the background rather than closed.

Choosing the owner at runtime Permalink to this section

Wrap both designs, plus the per-tab fallback, behind one function that every tab calls. The rest of the application only ever sees subscribe(topics, handler) and a status value:

Picking the stream owner in each tab Decision tree run in each tab at start-up, choosing a SharedWorker if available, otherwise leader election via Web Locks and BroadcastChannel, otherwise a stream in the tab itself. Picking the stream owner in each tab SharedWorker available? Connect to shared worker yes no Web Locks + BroadcastChannel? Elect a leader tab yes no Own stream in this tab
Every tab runs the same check and reaches the same answer, so all tabs in a profile agree on the owner.
export function createSharedStream(topics) {
  if (typeof SharedWorker === 'function') return viaSharedWorker(topics);
  if (navigator.locks && typeof BroadcastChannel === 'function') return viaLeaderElection(topics);
  return viaOwnEventSource(topics);                       // last resort: one stream per tab
}

Giving a new tab the current state Permalink to this section

A tab that opens after the stream has been running needs the current state, not just future events. Have the owner keep the latest snapshot of each state-shaped topic — unread counts, presence rosters, dashboard values — and send it to every newly connected tab before live events:

// In the SharedWorker
const latest = new Map();                                 // topic → last snapshot event
function forward(e) {
  if (e.type.endsWith(':snapshot') || STATE_TOPICS.has(e.type)) latest.set(e.type, e);
  broadcast({ kind: 'event', type: e.type, data: e.data, id: e.lastEventId });
}
self.onconnect = (ev) => {
  const port = ev.ports[0];
  for (const e of latest.values()) port.postMessage({ kind: 'event', type: e.type, data: e.data, id: e.lastEventId });
  // …then register the port as before
};

Append-shaped data such as notification lists is better loaded by the new tab over HTTP; the stream then only adds what arrives afterwards, and ids make the merge safe.

Using the shared stream from React Permalink to this section

export function useSharedStream(topic, onEvent) {
  const handler = useRef(onEvent);
  handler.current = onEvent;
  useEffect(() => {
    const stream = getSharedStream();                     // module-level singleton per tab
    return stream.subscribe(topic, (e) => handler.current(e));   // returns an unsubscribe
  }, [topic]);
}

Components subscribe and unsubscribe as they mount and unmount; the tab tells the owner its current topic set, and the owner reopens the connection only when the union across all tabs changes. The general hook shape is covered in building a useEventSource React hook.

Edge Cases & Network Interference Permalink to this section

Sharing moves the connection from a tab into shared infrastructure, and a few browser behaviours need handling:

  • Availability. SharedWorker is not available in every browser and mode; check typeof SharedWorker === 'function' and fall back. BroadcastChannel and Web Locks are widely available in current browsers; the final fallback is a stream per tab.
  • Background freezing. Browsers throttle and may freeze background tabs. A leader tab frozen in the background stops relaying, even though it still holds the lock. Prefer a visible tab as leader, or have followers detect silence (no events or heartbeats relayed for twice the heartbeat interval) and request a hand-over.
  • Private windows and profiles. Sharing happens per origin within one browser profile. A private window is a separate profile and holds its own stream, which is correct.
  • Authentication changes. When the user logs out in one tab, the shared stream must close for all of them. Broadcast a logout message and have the owner close the stream.
  • Different app versions. After a deploy, an old tab and a new tab may disagree about the message format. Include a protocol version in messages and have the owner reject connections from incompatible versions, prompting a reload.

Mitigation checklist:

Performance & Scale Considerations Permalink to this section

The benefit is proportional to how many tabs users keep open, which for tools used all day is often more than you would guess.

Server connections per active user Bar chart comparing average SSE connections per active user without sharing, with leader election and with a SharedWorker, for a productivity application. Server connections per active user One stream per tab 4.6 Leader election 1.08 (hand-overs) SharedWorker 1.02 average concurrent SSE connections per active user
Measured across a week of a tool used all day. Sharing turns connection count into a function of users, not tabs.

Beyond connection count, sharing reduces duplicated work in every layer: the server fans out once per browser instead of once per tab, the network carries each event once, and the browser parses it once. On HTTP/1.1, it also frees connection slots for ordinary API requests, which can make an application with many tabs noticeably faster.

Sharing also changes what the server’s connection metrics mean. Before the change, open streams tracked open tabs; afterwards they track active browsers. Capacity plans, per-user limits and alerts calibrated against the old numbers should be revisited when sharing ships, or alerts will fire on the sudden drop and limits will be far looser than intended. A useful derived metric is streams per active user: it should sit just above one for users on a single device, and rising values point at tabs that fell back to their own streams.

Reconnect storms shrink too. After a server deploy, every browser reconnects once instead of once per tab, so the reconnect wave that nodes must absorb is smaller by the same factor as the connection count, and replay load falls with it.

The costs are small: one extra postMessage per event per tab (structured cloning of a string is cheap), and a little complexity in lifecycle handling. For very high-rate streams, forward batches rather than individual events.

Validation & Debugging Permalink to this section

# Server side: count connections per user while opening several tabs.
curl -s localhost:9100/metrics | grep 'sse_open_streams_by_user{user="u42"}'

In Chrome, chrome://inspect/#workers lists running shared workers and lets you open DevTools for them, where the Network panel shows the single stream and its EventStream tab. In Firefox, about:debugging#workers does the same. For leader election, log which tab holds the lock and add the tab’s id to relayed messages in development builds, so it is obvious which tab is relaying.

Automate the multi-tab scenarios with a browser test framework: Playwright can open several pages in one browser context, which share SharedWorkers, BroadcastChannels and locks exactly like real tabs. Assert that the server sees one connection for the context, that an event published once reaches every page, and that closing the owning page does not interrupt delivery to the others. The techniques are the same as in end-to-end testing SSE with Playwright.

Test the hand-over explicitly: open three tabs, close the leader (or the tab that created the shared worker), and confirm that events keep arriving in the remaining tabs without a gap and without duplicates.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Is HTTP/2 enough to make sharing unnecessary?

HTTP/2 removes the six-connection limit, so tabs no longer block each other. Sharing still reduces server connections, fan-out work and bandwidth by the number of tabs a user keeps open.

SharedWorker or leader election?

Prefer a SharedWorker where available: it outlives individual tabs and has no hand-over gaps. Use leader election where SharedWorker is unavailable, and keep a stream per tab as the last resort.

Can a service worker own the stream instead?

Service workers are terminated when idle and are not designed to hold long-lived connections, so they are a poor owner for an SSE stream. Use a SharedWorker or a leader tab.

Do tabs on different subdomains share a stream?

No. SharedWorker, BroadcastChannel and Web Locks are scoped to a single origin, so app.example.com and admin.example.com each hold their own stream. That is usually desirable, since they serve different data.

What happens to toasts and sounds when every tab receives the event?

Every tab would show them. Decide which tab reacts — the focused one, or the leader if none is focused — and let the others update state silently.

How do tabs get the current state when they open?

The owner keeps the latest snapshot of state-shaped data and sends it to each newly connected tab, or the tab fetches it over HTTP. Only live changes come through the shared stream.

Deep Dives