This page implements the coordination primitive referenced in Cross-Tab State Synchronization, where the message fan-out and echo suppression are covered.
The exact problem #
A user has six tabs of an internal tool open. Each one opens a WebSocket, each one runs a thirty-second poller, and each one writes the same “seen at” record on a timer. The server sees six connections and six times the write volume from one person, and the user’s laptop fan is audible.
Worse, the writes race: two tabs update the same record from slightly different local state, and the last one wins arbitrarily. The fix is to make exactly one tab responsible for talking to the server, and to have the browser — not your code — decide which one.
Zero-to-working election #
// leader.ts
export type Role = 'leader' | 'follower' | 'unsupported';
interface LeaderOptions {
lockName?: string;
onBecomeLeader: (signal: AbortSignal) => void | Promise<void>;
onRoleChange?: (role: Role) => void;
}
export function runLeaderElection({
lockName = 'app-leader-v1',
onBecomeLeader,
onRoleChange,
}: LeaderOptions): () => void {
if (!('locks' in navigator)) {
onRoleChange?.('unsupported');
// Degrade: act alone, with idempotent writes, rather than not at all.
void onBecomeLeader(new AbortController().signal);
return () => {};
}
const controller = new AbortController();
onRoleChange?.('follower'); // until proven otherwise
// request() resolves the callback ONLY when the lock is acquired; every other
// tab waits in the queue. Held until the returned promise settles.
void navigator.locks.request(lockName, { signal: controller.signal }, async () => {
onRoleChange?.('leader');
await onBecomeLeader(controller.signal);
// Never resolve while we are alive: the lock is our leadership.
await new Promise<void>((resolve) => {
controller.signal.addEventListener('abort', () => resolve(), { once: true });
});
}).catch((err) => {
// AbortError just means we gave up waiting or shut down.
if (!(err instanceof DOMException && err.name === 'AbortError')) {
reportCrash({ scope: 'leader-election', message: String(err) });
}
});
return () => controller.abort(); // relinquish or stop queuing
}
// app.ts — leader-only work, follower fan-out
const channel = new BroadcastChannel('app-data-v1');
const stop = runLeaderElection({
onRoleChange: (role) => store.setRole(role),
onBecomeLeader: (signal) => {
const socket = new WebSocket('/ws/updates');
socket.onmessage = (event) => {
const update = JSON.parse(event.data);
store.apply(update); // leader applies locally…
channel.postMessage({ type: 'update', update }); // …and fans out
};
const poll = setInterval(() => void refreshSlowData(), 30_000);
signal.addEventListener('abort', () => {
clearInterval(poll);
socket.close(1000, 'leadership released');
});
},
});
// Followers never open a connection; they just listen.
channel.onmessage = (event) => {
if (event.data?.type === 'update') store.apply(event.data.update);
};
window.addEventListener('pagehide', stop);
Step-by-step #
- Use one stable lock name. Version it (
app-leader-v1) so a future incompatible protocol can elect a separate leader during a rolling deploy. - Never resolve the callback while you want to lead. The lock is held exactly as long as the promise returned from the callback is pending. Resolving it is how you stop being leader.
- Pass the
AbortSignalinto the work. Everything the leader starts must stop when leadership ends — the same teardown discipline as Clearing Timers and Subscriptions When a Boundary Unmounts. - Fan out, do not re-fetch. Followers must never open their own connection “just for freshness”; that reintroduces exactly the cost the lock removed.
- Handle the unsupported case by degrading, not disabling. Every tab acting independently with idempotent writes is wasteful but correct; refusing to run is neither.
- Abort on
pagehide. Explicit release makes failover instant on a normal close; the browser handles the crash case for you.
| Event | Who does what | Latency |
|---|---|---|
| Second tab opens | Queues for the lock, renders as follower | Immediate |
| Leader tab closed by user | Browser releases lock; next queued tab promoted | Milliseconds |
| Leader tab crashes | Same — the context is gone, the lock is released | Milliseconds |
| Leader navigates away | Same | Milliseconds |
| Leader tab hidden and throttled | Still leader; timers may slow | Consider relinquishing |
| All tabs closed | Lock disappears with the origin’s contexts | n/a |
Making leadership visibility-aware #
Background tabs get throttled: setInterval can be clamped to once a minute, and a hidden leader means a visible follower shows stale data. Relinquishing on hide fixes it.
// visibilityAwareLeader.ts
let stop = runLeaderElection({ onBecomeLeader: startWork, onRoleChange: paintRole });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// Step aside so a visible tab can lead — but only if one exists.
setTimeout(() => {
if (document.visibilityState === 'hidden') { stop(); stop = () => {}; }
}, 5000);
} else if (stop === (() => {}) || !isLeader()) {
stop = runLeaderElection({ onBecomeLeader: startWork, onRoleChange: paintRole });
}
});
The five-second delay matters: without it, a user alt-tabbing away for a moment causes a leadership churn that closes and reopens the socket. And because a lone hidden tab will simply re-acquire the lock immediately, stepping aside is safe even when there is no visible tab to take over.
Verification #
- Open six tabs. The server should see exactly one connection. Check the WS panel in each tab: five must show none.
- Kill the leader. Close the leader tab; another must open a connection within a few hundred milliseconds, with no gap in the followers’ data beyond the in-flight update.
- Crash the leader. Force a renderer crash (
chrome://crashin a spare tab, or a memory bomb); failover must behave identically to a clean close. - Check write volume. Trigger the periodic write with six tabs open and confirm exactly one request per interval.
- Test the unsupported path. Stub
navigator.locksas undefined; every tab should work independently and writes must be idempotent — the guarantee described in Aborting In-Flight Fetches with AbortController on Boundary Reset.
A small operational detail: name the lock after the capability rather than the application — orders-socket, not app-leader — when different subsystems need independent leaders. One tab can then own the notification socket while another owns a long-running export, which is both more resilient and easier to reason about than a single leader that owns everything.
Frequently asked questions #
What happens to the lock if the leader tab crashes?
The browser releases it when the holding context disappears, so the next queued tab’s callback runs immediately. Failover needs no heartbeat or timeout.
Do I need a fallback for browsers without navigator.locks?
Plan for absence: fall back to every tab acting independently with idempotent writes, or a localStorage lease with a heartbeat. Degrade to wasteful-but-correct.
Should a background tab be allowed to be the leader?
It can be, but throttling slows its timers. If freshness matters, relinquish the lock when hidden and reclaim it when visible.
Related #
- Cross-Tab State Synchronization — the broadcast layer followers rely on
- Syncing Auth and Logout Across Tabs with BroadcastChannel — the message contract this fan-out uses
- Resolving a Draft Conflict When Two Tabs Edit the Same Document — what to do when leadership was not enough