On staging, a tab left open from before a deploy made every new tab on the new version wait forever. Closing the old tab fixed it. That bug is one reason the code below looks the way it does.
I'm building a realtime issue tracker where the browser keeps a full copy of the workspace in IndexedDB and syncs over a WebSocket. Open the same workspace in ten tabs and you'd get ten sockets, ten writers to one database, and ten copies of every change racing into the same object stores. I wanted one socket per browser. This is how the tabs agree on which one.
The election is a Web Lock
No chatting, no heartbeats, no timestamps. Every tab asks for the same lock, and the browser lets one of them have it:
// simplified from tabs.ts
elect(onLeader) {
navigator.locks
.request(name, { signal: abort.signal }, () => {
onLeader();
// Hold the lock, and with it the leadership, until this tab closes.
return new Promise<void>((resolve) => {
release = resolve;
});
})
.catch(() => {});
},
navigator.locks.request queues requests for the same name and runs the callback of the first one. The lock is held until the promise the callback returns settles. Mine settles only when close() calls release, so the tab that got the callback leads for as long as it's open. When a tab goes away, the browser releases its lock and the next tab in the queue is called. There's no code for "the leader died": the queue is the failover.
The AbortController covers the other direction. A tab that closes while still waiting in the queue withdraws its request.
Talking is a BroadcastChannel
The lock decides who leads. It carries no data. Tabs talk over a BroadcastChannel with the same name, and the whole vocabulary is seven message types: mutation, delta, ack, reject, reload, status and status?.
-
Incoming data. The leader gets a delta from the server, writes it to IndexedDB, and only then posts
deltato the channel. Followers apply it to their in-memory copy. -
Outgoing edits. A tab applies its own mutation optimistically, stores it in IndexedDB's
pendingstore, and postsmutation. Every tab adds it to its pending list, so the edit shows in all of them at once. Only the leader sends it, and later postsackorreject.
"Write, then announce" matters. Every storage write is queued, and the message goes out after the write completes. A follower that misses a message or sees a gap in versions re-reads IndexedDB, and because of that ordering it finds at least what it was told about:
case "delta":
if (!this.#replica.applyDelta(message)) void this.#reload();
return;
applyDelta returns false when the delta starts past the version the tab has, which is a gap. A follower never tries to repair a gap itself; it reloads from storage. The leader ignores delta messages from the channel entirely. It hears from the server directly, and anything else reaching it is from a previous leader.
One more detail: a starting tab subscribes to the channel before it loads from IndexedDB, holds whatever arrives during the load, and replays it afterwards.
When the leader goes away
The test I like most is the ugly case. A follower edits while the network is down. Its mutation reaches the leader over the channel, which is local and still works, and waits there. Then the leader closes before it can send anything:
await leader.close();
network.online();
await confirmed; // the follower's own promise
await waitFor(() => bob.getState().issues.size === 1);
The follower is next in the lock queue, becomes the leader, connects, and when the server says ready it resends every mutation still in its pending list. Nothing is lost because the pending list lives in IndexedDB and in every tab's memory, not only in the leader. Resending is safe because each mutation has an id and the server applies an id once. The follower's mutate() promise, made before it was the leader, resolves when the new leader's ack arrives.
What I didn't write: freezing and background tabs
I expected to need code for a leader that is hidden or frozen. There isn't any, and I'd rather say so than invent it. The lock is held until the tab is actually gone. What exists is a keepalive: the leader pings every 30 seconds, and if no pong comes within 10, it treats the connection as lost and reconnects. It also retries right away when the browser fires online or the tab becomes visible again. A hidden page paints no frames, so UI updates wait 16 ms on a timer instead of requestAnimationFrame. I have no test for a throttled or frozen leader, only for what the code does.
Two bugs from a leader that outlives its use
1. The lock was named for the replica only. That's the bug from the top. After a deploy that bumped the protocol version, a tab left open on the old version kept leading. It wrote the old version's database and spoke the old messages, and the new tab waited as its follower. It happened on staging with an issue page left open. The fix was to put the protocol version in the name of both the lock and the channel:
export function tabsName(storageKey: string, protocolVersion = PROTOCOL_VERSION) {
return `track:${storageKey}:v${protocolVersion}`;
}
Each version elects its own leader. The test takes the old version's lock by hand, opens a tab on the new one, and checks that it connects at once.
2. A signed-out leader blocks the next sign-in. A leader keeps its lock as long as its tab is open, even after its session ends. Sign in again in a new tab and it would follow a leader that could no longer connect, stuck on "Connecting" with no error. Now a new tab posts status?, and a leader that is unauthorized tries to connect again instead of only answering. It does this only when asked. Retrying on its own, on focus for instance, could connect one person's replica to another person's session.
There was a second way into the same trap. Signing out in one tab deletes the IndexedDB database. The old leader carried on from memory while every tab opened afterwards read an empty database and waited for it. Now the database's blocking callback tells the page, which reloads and lets go of the lock. A schema upgrade by a newer build trips the same callback, so the code tells the two apart: a delete has no target version.
The browser tests for these run several real tabs in one Playwright context, sign out in one, and sign in again in another.
Was it worth it?
Yes, mostly because failover costs nothing. The hard part is one fact: the leader holds the lock until its tab closes, whether or not it can still do its job. If I did it again I'd write down on day one every way a leader can become useless without dying. I found two by getting stuck behind one.
If you've built tab coordination, did you use Web Locks, a localStorage heartbeat or a SharedWorker? And what did a frozen tab do to it?
The code is open source if you want the details: packages/sync-client/src/tabs.ts in the tracker's repository.
I drafted this with an AI assistant (Claude), which also wrote much of the code it describes. I checked the facts and numbers against the code.
Top comments (0)