Open a web app twice and it quietly does everything twice. Why BroadcastChannel is not enough, and how leader election picks exactly one tab to do the work.
Your app works. Then a user opens it in a second tab, and things start going
wrong in ways that never show up in testing.
Both tabs poll the server, so your request volume doubles for one user. Both
hold a WebSocket, so your connection count is wrong and your server thinks you
have twice the traffic you do. Both raise the same desktop notification, so it
appears twice. The user signs out in one tab and the other carries on as though
nothing happened, still showing their data, still refreshing it.
None of this is exotic. Users open second tabs constantly — middle-clicking a
link, restoring a session, or just forgetting the first one is there. It is
routine, and almost no web app handles it.
The platform has a primitive for this. BroadcastChannel is a named pipe
between same-origin browsing contexts: post a message on one side, every other
tab on that channel receives it.
That is genuinely useful, and it is where the platform stops. Line up what you
need against what you get:
| What you need | What the platform gives you |
| --- | --- |
| Messages with a sender | Untyped structured clones, no identity |
| How many tabs are open | Nothing — you maintain it by heartbeat |
| Exactly one tab doing the work | Nothing at all |
The first gap is an annoyance: every message arrives anonymous, so you invent an
envelope with a sender id in it, and every app invents a slightly different one.
The second is real work. There is no tabs.count. To know how many tabs exist
you broadcast a heartbeat on an interval, track who you have heard from, and
expire the ones that go quiet — and now you own a distributed-systems problem
with timeouts to tune.
The third is the one that actually matters.
Polling, holding a socket, raising a notification, running a background sync —
these should happen once per user, not once per tab. That means the tabs have
to agree on which of them is responsible, and re-agree when that one closes,
without a server and without asking the user.
That is leader election, and it is the part worth a package.
final tabs = CrossTab.open('my-app');tabs.presence.listen((p) { if (p.isLeader) { socket.connect(); } else { socket.disconnect(); }});One tab connects. The others do not. Close the connected one and another picks
it up on its own.
The rule is: the oldest tab leads, with the tab id as a tiebreak. The second
half of that sentence is doing more work than it looks.
Suppose you elect purely on age. Two tabs open in the same millisecond — a
session restore opening several at once, which is exactly when this happens —
and now they have equal claim. Each looks at the other, sees a tie, and applies
whatever rule it has. If the rule is "the other one is not older than me, so I
lead," they both lead: two sockets, two pollers, the bug you were trying to fix.
If the rule is "I am not older, so I defer," neither leads and the work stops
entirely.
Adding the id as a second comparison key makes the ordering total. There is
no such thing as a tie, so every tab independently reaches the same answer with
no negotiation round, no lock, and no coordinator. Comparing (age, id) is a
few characters more code than comparing age, and it is the difference between
an algorithm that converges and one that has a race in it.
Two ways a leader stops being a leader, and they need different handling.
A tab that closes says goodbye. beforeunload fires, it broadcasts that it
is leaving, and the others re-elect immediately. This is the common case, and
handling it explicitly is why closing a tab does not produce a gap where nothing
is polling.
A tab that crashes says nothing. No event fires for a killed renderer, an
out-of-memory tab, or a laptop lid closed on a suspended process. So there has
to be a timeout: miss three heartbeats and you are dropped.
You want both. Only the timeout, and every ordinary tab close leaves the app
headless for several seconds. Only the goodbye, and one crash leaves a dead tab
nominally in charge forever.
A small design decision with outsized consequences:
final p = tabs.current;p.count; // how many tabs are openp.tabs; // their ids, including this onep.leader; // whose turn it is, or null while an election settlesp.isLeader; // whether that is you
presence emits the current state when you subscribe, not only when
something next changes.
If it only emitted on change, a tab that subscribes during a quiet period sits
there knowing nothing until some other tab opens or closes. Your leader check
never runs, so the socket never connects, and the bug looks like "sometimes it
just does not start" — the worst kind, because it depends on timing you cannot
reproduce on demand.
Note also that leader is nullable. During an election there is genuinely no
answer, and saying null is more honest than nominating a placeholder that
callers then have to distrust.
tabs.send({'cart': items.length});tabs.messages.listen((m) => print('from ${m.from}: ${m.data}'));The sender does not receive its own message, because it already knows what it
sent.
That sounds obvious and it removes a real class of bug. If you did receive your
own broadcasts, every handler would need a if (m.from == myId) return; guard,
and the one place you forget is an infinite loop: receive, update state, notify
others, receive.
The package compiles everywhere. On mobile and desktop there is exactly one
instance of your app, so it reports one tab and is always the leader.
That is the correct answer rather than a degraded one, and the distinction
matters. It means the code above runs unchanged on every platform — no
if (kIsWeb) wrapped around your leader logic, no separate code path to keep in
sync, no chance of the mobile build silently skipping the work because nobody
was elected.
A cross-platform package that throws on non-web would push that branch into
every call site. Answering truthfully — one instance, and it leads — makes the
branch unnecessary.
The nice surprise: this is genuinely testable, not mock-only.
Two BroadcastChannel objects with the same name **see each other even within
one document**. So a test can construct four instances in a single Dart VM under
Chrome and watch them behave like four real tabs, exercising the actual browser
primitive rather than a stand-in for it.
flutter test --platform chrome covers four tabs agreeing on a single leader,
the oldest tab winning, leadership passing when the leader closes, tabs
discovering and losing each other, and a tab not hearing its own messages. The
single-instance path is tested on the VM.
That matters because leader election is exactly the kind of code where a mocked
test proves your mock works. The failure modes live in the timing — two tabs
starting together, a leader vanishing mid-heartbeat — and a fake channel with
synchronous delivery has none of that.
Once you have this, the question becomes what belongs there. A rough rule:
**anything that talks to a server on a timer, or that the user should experience
once rather than once per tab.**
Good candidates are polling loops, WebSocket and SSE connections, background
sync, desktop notifications, and scheduled cache refreshes.
Poor candidates are anything the user is looking at. Do not gate rendering,
local state, or reading from cache on leadership — a non-leader tab is still a
tab someone is using, and it should feel identical. The leader does the work;
every tab shows the result.
And separately from leadership, plain broadcast is the right tool for
cross-tab state: sign-out, cart changes, theme switches. Every tab should react
to those, immediately, without a refresh.
sockets and your notifications.
BroadcastChannel is a pipe. It has no identity, no count, and no leader.ordering total, so tabs converge without negotiating.
learns where it stands.
runs everywhere with no kIsWeb branch.
cross_tab is on pub.dev — MIT, 160/160 pub points, all
six platforms, and the multi-tab behaviour is exercised against real
BroadcastChannel objects rather than mocks.