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.
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.
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:
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.
SharedWorkeris not available in every browser and mode; checktypeof SharedWorker === 'function'and fall back.BroadcastChanneland 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.
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.