Leader Election with BroadcastChannel for SSE Permalink to this section
Part of Sharing One SSE Connection Across Tabs, under Frontend Consumption & Client Patterns.
When a SharedWorker is not available, tabs can still share one Server-Sent Events stream: elect one tab as leader, let it hold the stream, and have it rebroadcast events to the others over a BroadcastChannel. The election is the delicate part. Home-grown elections based on timestamps in localStorage produce two leaders or none under real conditions. The Web Locks API makes it simple and correct, and the remaining work is the hand-over — making sure the new leader resumes exactly where the old one stopped, and noticing a leader that is alive but frozen.
Symptom & Developer Intent Permalink to this section
- Two tabs both believe they are the leader, and the server sees two streams (or every event appears twice).
- When the leader tab closes, the others stop receiving events until the page is reloaded.
- After a hand-over, a few events are missing or repeated.
- A leader tab in the background on a laptop stops relaying for minutes at a time.
The intent is exactly one stream per browser profile, a seamless hand-over when the leader leaves, and no stale leader.
Root Cause Analysis Permalink to this section
An election needs mutual exclusion that survives tab crashes. localStorage heartbeats cannot provide it: two tabs can read a stale heartbeat at the same moment and both take over, and a crashed leader’s entry lingers until someone decides it is old. Web Locks are managed by the browser: navigator.locks.request(name, callback) grants the lock to one context at a time, holds it until the callback’s promise settles, and releases it automatically if the tab closes or crashes. Waiting requests are queued, so the next tab in line becomes leader immediately.
Missing or repeated events at hand-over come from the cursor: the new leader must know the last event id that reached the tabs. Frozen leaders come from background throttling and freezing: the leader still holds the lock but its code is not running.
Step-by-Step Resolution Permalink to this section
Step 1 — Elect with Web Locks Permalink to this section
const channel = new BroadcastChannel('sse-v3'); // version the channel name with the protocol
let isLeader = false;
let abdicate = null; // resolves the lock-holding promise
navigator.locks.request('sse-leader-v3', async () => {
isLeader = true;
const stop = openLeaderStream();
channel.postMessage({ kind: 'leader', tab: TAB_ID });
await new Promise((resolve) => { abdicate = resolve; }); // hold the lock until asked to give it up
stop();
isLeader = false;
});
Every tab calls this at start-up. Exactly one callback runs at a time; the others wait in the browser’s queue.
Step 2 — Keep a shared cursor Permalink to this section
The leader writes the last relayed id where every tab can read it synchronously on take-over. Every tab also updates it from relayed messages, so it is current whichever tab becomes leader:
function relay(e) {
const msg = { kind: 'event', type: e.type, data: e.data, id: e.lastEventId };
if (msg.id) localStorage.setItem('sse-cursor', msg.id);
deliver(msg); // leader's own UI
channel.postMessage(msg); // every other tab
}
channel.onmessage = ({ data }) => {
if (data.kind !== 'event') return;
if (data.id) localStorage.setItem('sse-cursor', data.id);
deliver(data);
};
function openLeaderStream() {
const after = localStorage.getItem('sse-cursor') ?? '';
const es = new EventSource(`/api/stream?after=${encodeURIComponent(after)}`, { withCredentials: true });
EVENT_TYPES.forEach((t) => es.addEventListener(t, relay));
return () => es.close();
}
localStorage is shared per origin and survives the leader’s crash, which sessionStorage (per tab) does not. The server treats after exactly like Last-Event-ID. Tabs deduplicate by id, so the small overlap a hand-over may produce is harmless.
Step 3 — Detect and replace a frozen leader Permalink to this section
Followers watch for relayed traffic, including heartbeats. The leader relays a heartbeat message whenever it receives one from the server, so silence from the leader means it is not running:
let lastRelay = Date.now();
channel.addEventListener('message', () => { lastRelay = Date.now(); });
setInterval(() => {
if (!isLeader && document.visibilityState === 'visible' && Date.now() - lastRelay > 45_000) {
channel.postMessage({ kind: 'abdicate-request' }); // ask the leader to step down
lastRelay = Date.now();
}
}, 5_000);
channel.addEventListener('message', ({ data }) => {
if (data.kind === 'abdicate-request' && isLeader && document.visibilityState === 'hidden') abdicate?.();
});
A frozen leader will not process the request until it is thawed; for that case, prefer leaders that are visible from the start by having hidden tabs abdicate when a visible tab asks, and by re-requesting the lock after abdicating so the old leader rejoins the queue.
Step 4 — Hand leadership to the visible tab Permalink to this section
For users who keep one tab in front, making that tab the leader avoids background throttling entirely. On visibilitychange to visible, a follower can ask a hidden leader to abdicate and then request the lock; the hand-over costs one reconnect with resume.
Step 5 — Handle logout and versions Permalink to this section
Broadcast logout on the channel; the leader closes its stream and every tab clears its state. Include the protocol version in the channel and lock names (sse-v3), so tabs from an older deploy form their own group rather than exchanging incompatible messages.
Validation & Monitoring Permalink to this section
- Open four tabs; the server should show one stream for the browser.
- Close the leader tab; within a second another tab opens a stream starting after the last relayed id, and events continue in every tab without gaps.
- Kill the leader with the browser’s task manager; the same hand-over happens, because the lock is released on crash.
- Background the leader and wait; if relays stop, a visible follower triggers a hand-over.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why not elect a leader with localStorage timestamps?
Reads and writes from several tabs race, so two tabs can both claim leadership, and a crashed leader is only detected after a timeout. Web Locks give the browser the job of guaranteeing one holder.
Does the lock survive a page reload?
No. Reloading releases the lock, and another waiting tab — or the reloaded tab when it requests again — becomes leader. The shared cursor makes the reload invisible to the other tabs.
Is BroadcastChannel delivery reliable?
It delivers to every same-origin context that has the channel open at the time. A tab that opens later misses earlier messages, which is why new tabs load current state over HTTP or from a snapshot the leader rebroadcasts.
How is this different from a SharedWorker?
A SharedWorker is a separate context that outlives any tab, so it never hands over. Leader election uses one of the tabs, which is simpler to deploy but requires hand-over logic when that tab leaves.