Flutter Web in Two Tabs: Both Polling, Both Wrong
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. - BroadcastChannel is 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 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. 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)
Comments
No comments yet. Start the discussion.