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.
What the browser gives you, and where it stops
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.
Exactly one tab should do the work
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 tiebreak is the whole algorithm
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.
Leaving politely, and crashing
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.
Presence emits on subscription
A small design decision with outsized consequences:
final p = tabs.current;
p.count; // how many tabs are open
p.tabs; // their ids, including this one
p.leader; // whose turn it is, or null while an election settles
p.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.
A tab never hears itself
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.
Off the web, it is always the leader
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.
Testing multi-tab behaviour for real
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.
What to put behind the leader check
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.
The short version
- Second tabs are routine, and an unhandled one doubles your polling, your sockets and your notifications.
-
BroadcastChannelis a pipe. It has no identity, no count, and no leader. - Elect on (age, id), never age alone. The tiebreak is what makes the ordering total, so tabs converge without negotiating.
- Handle both departures: a goodbye on close, and a timeout for a crash.
- Emit presence on subscribe, or a tab that starts during a quiet period never learns where it stands.
- Answer honestly off the web — one instance, always leader — so the same code
runs everywhere with no
kIsWebbranch.
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.
Originally published at devshakib.jumyn.com. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at pub.dev/publishers/jumyn.com.
Top comments (0)