devShakib

Flutter Web in Two Tabs: Both Polling, Both Wrong

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.

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 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.

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

sockets and your notifications.

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.