Sharing One Server-Sent Events Connection Across Browser Tabs

Every browser tab that opens an EventSource holds its own connection. How tabs can share one connection instead, using leader election over BroadcastChannel.

Manula Pasan October 31, 2026
Dev log cover - Sharing One Server-Sent Events Connection Across Browser Tabs

Part 1 of a series on real-time notifications with Server-Sent Events.

I built live notifications for a web app, pushed from the server with Server-Sent Events, and researched how to do it properly. The question that needed the most research only shows up with several tabs open: every tab opens its own connection. This part covers what that costs, and how tabs can share one connection instead.

The cost of a connection per tab

An EventSource holds its connection open for as long as the tab is alive. Over HTTP/1.1, a browser allows six connections to the same site, shared by every tab it has open, so six tabs of an SSE app can take every one of them. Every other request to that site - API calls, images, the next page - then waits.

HTTP/2 raises that limit a long way by multiplexing streams over one connection, usually up to about 100 at a time. The server still holds a stream and a subscriber for every tab, though, all for a single person.

Six tabs each holding their own connection, compared with one leader tab sharing a single connection over BroadcastChannel
Each tab connecting uses every connection HTTP/1.1 allows. A leader tab needs one.

Choosing a leader

Sharing one connection is a leader election. One tab, the leader, holds the stream and the other tabs listen to it. Browsers offer two ways for tabs to talk to each other:

  • localStorage - a write in one tab fires a storage event in the others.
  • BroadcastChannel - a message bus between tabs on the same origin.

localStorage is the wrong tool for this. You can run a heartbeat on it - the leader writes a timestamp every few seconds and the others watch for it - but it's shared state, not messages. Whatever a leader writes stays written: a tab that crashes leaves its "I'm the leader" marker behind, and localStorage survives even a browser restart. Every heartbeat is also a synchronous write to disk, and there's no atomic way to claim a key, so two tabs can both believe they won.

BroadcastChannel carries messages and nothing else. A leader that dies just stops sending, and leaves nothing behind. Detecting that takes a heartbeat: the leader sends a message every few seconds, and each of the other tabs restarts a timer when one arrives. If a timer runs out, the leader has gone quiet and that tab starts a new election.

Animation: tab 1 leads and sends heartbeats that restart the other tabs' timers. Tab 1 crashes, the timers run down, and tab 2 takes over the connection.
When the leader goes quiet, the other tabs' timers run out and the oldest remaining tab takes over.

Three rules for a stable leader

  • Make the timeout several times the heartbeat. Timers drift, and browsers throttle timers in background tabs. With the two too close together, a healthy leader keeps getting replaced. Here the heartbeat is 5 seconds and the timeout 20.
  • Break ties the same way in every tab. If two tabs both end up leading, they need a rule they can each apply without talking, such as "the oldest tab keeps it". If both simply step down, nobody leads.
  • Announce leadership the moment you claim it. Tabs that start together, such as after a session restore, all look for a leader at once. A tab that only announces after its stream opens leaves a gap in which the others claim too.

The implementation

Each tab waits a random moment, asks whether a leader exists, and waits 800ms for an answer. If none arrives, it claims the connection. The leader announces itself every 5 seconds and relays every event to the other tabs. They take over after 20 seconds of silence, or immediately if the leader says goodbye as its tab closes.

const id = `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const bus = new BroadcastChannel('push');
let leader = false, heard = false, timer, beat, stream;
const send = (type, x) => bus.postMessage({ type, from: id, ...x });
const watch = () => { clearTimeout(timer); timer = setTimeout(elect, 20_000); };

function elect() {
  if (leader) return;
  heard = false;
  send('ping');
  setTimeout(() => (heard ? watch() : claim()), 800);
}
function claim() {
  leader = true;
  send('alive'); // announce first
  beat = setInterval(() => send('alive'), 5_000);
  stream = openStream((data) => { render(data); send('event', { data }); });
}
function stepDown() {
  leader = false;
  clearInterval(beat);
  stream.close();
  watch();
}

bus.onmessage = ({ data: m }) => {
  if (m.type === 'ping' && leader) send('alive');
  if (m.type === 'alive') {
    heard = true;
    if (!leader) watch();
    else if (m.from < id) stepDown(); // oldest tab keeps it
  }
  if (m.type === 'bye' && !leader) setTimeout(elect, 100);
  if (m.type === 'event') render(m.data);
};
addEventListener('beforeunload', () => leader && send('bye'));
setTimeout(elect, Math.random() * 1000);

Tab IDs begin with a timestamp, so comparing two of them identifies the older tab.

Edge cases worth handling

  • The leader is killed, not closed. beforeunload doesn't fire when a mobile browser discards a tab or the browser crashes, so no goodbye is sent - and on mobile it's unreliable even for a normal close, where pagehide fires more consistently. The other tabs wait out the timeout instead, and the shared count can be up to 20 seconds stale before one takes over. A shorter timeout narrows that gap at the cost of more false takeovers - that's the trade-off the timeout controls.
  • The leader's tab is in the background. Chrome can slow timers in a tab that's been hidden for five minutes down to about once a minute. A hidden leader's heartbeat then arrives late enough for another tab to take over, and with "oldest tab keeps it", the older hidden tab can win the tie-break straight back. Leave tabs hidden for a while when you test this.
  • A tab comes back from the background. Re-run the election when a tab becomes visible again. A laptop waking from sleep shouldn't trust whatever state its tabs held before.
  • Duplicated tabs. If you store the tab ID in sessionStorage, duplicating a tab copies it, and two tabs end up sharing one ID. Each ignores the other's messages as its own, so both can lead. Generate the ID fresh on every page load, as the snippet does.
  • No BroadcastChannel. Every current browser supports it. Where it's missing, let each tab connect on its own - no worse than not sharing at all.

A simpler option: the Web Locks API

The election above works, but its leadership half has a built-in alternative. The Web Locks API grants a named lock to one tab at a time and queues the rest. The browser releases the lock when the holding tab closes or crashes, so the next tab in the queue takes over with no heartbeat and no timeout. BroadcastChannel is still how the leader shares events.

const bus = new BroadcastChannel('push');
bus.onmessage = ({ data: m }) => { if (m.type === 'event') render(m.data); };

navigator.locks.request('push-leader', () => {
  // Only one tab at a time gets here; the others wait in the queue.
  openStream((data) => { render(data); bus.postMessage({ type: 'event', data }); });
  // Hold the lock for the life of the tab. When the tab closes or crashes,
  // the browser releases it and the next tab in line becomes the leader.
  return new Promise(() => {});
});

Every current browser supports it, and it sidesteps most of the edge cases above - there's no timer to throttle, no timeout to tune and no tie to break. If you're building this from scratch, start here.

When a stream per tab is fine

This adds real moving parts, so it's worth knowing when to skip it. Over HTTP/1.1 it matters a lot: the six-connection limit turns a handful of tabs into a frozen app. Over HTTP/2 the browser limit stops biting, and the question becomes server load - one subscriber per person rather than one per tab. If your users rarely keep more than a couple of tabs open, a stream per tab is simpler and perfectly fine.

Next: keeping that one connection alive.

Keep reading