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);
One leader holds the connection; followers receive a fan-out Four tab boxes on the left. One is marked leader and connects to a server box with a single socket line. The other three are marked follower and queued for the lock, and receive updates from the leader through a broadcast channel box rather than from the server directly. tab 1 — leader holds the lock tab 2 — queued tab 3 — queued tab 4 — queued one socket server BroadcastChannel fan-out leader re-publishes every update six tabs → one connection one poller, one writer failover is automatic no heartbeats to tune

Step-by-step #

  1. Use one stable lock name. Version it (app-leader-v1) so a future incompatible protocol can elect a separate leader during a rolling deploy.
  2. 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.
  3. Pass the AbortSignal into the work. Everything the leader starts must stop when leadership ends — the same teardown discipline as Clearing Timers and Subscriptions When a Boundary Unmounts.
  4. Fan out, do not re-fetch. Followers must never open their own connection “just for freshness”; that reintroduces exactly the cost the lock removed.
  5. Handle the unsupported case by degrading, not disabling. Every tab acting independently with idempotent writes is wasteful but correct; refusing to run is neither.
  6. 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.

Failover when the leader tab dies Two lanes. The leader lane shows an active period ending abruptly at a crash marker. The follower lane shows a queued period that ends at the same marker, immediately followed by an active leader period. A caption notes that the browser releases the lock, so promotion needs no timeout. tab 1 leader: socket + poller tab 2 queued on the same lock tab 1 crashes promoted: opens the socket the browser released the lock when the context died — no heartbeat, no timeout, no split brain followers keep rendering the last broadcast state throughout Stepping aside when the leader is hidden A timeline showing tab one leading while visible, becoming hidden, waiting five seconds, then releasing the lock. Tab two, which is visible, immediately acquires it and continues the work at full timer resolution. tab 1 leading, visible hidden, 5 s grace tab 2 queued, rendering from broadcasts promoted — timers run unthrottled lock released the grace period stops a quick alt-tab from churning the socket

Verification #

  1. Open six tabs. The server should see exactly one connection. Check the WS panel in each tab: five must show none.
  2. 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.
  3. Crash the leader. Force a renderer crash (chrome://crash in a spare tab, or a memory bomb); failover must behave identically to a clean close.
  4. Check write volume. Trigger the periodic write with six tabs open and confirm exactly one request per interval.
  5. Test the unsupported path. Stub navigator.locks as 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.