How two browser tabs talk
Two screens that must never disagree, no server allowed. BroadcastChannel has been in browsers since 2015 and most of us have never opened it.
- browser
- javascript
- broadcastchannel
Two screens, one truth. That was the whole requirement.
A school ran a recruitment fair over two days and something like 500 people came through it. Staff needed a control panel — call the next number, skip a no-show, undo the mistake they just made. Candidates in the waiting area needed a display screen showing the current number, in Arabic and English. The two had to agree, always, because the failure mode is not a cosmetic glitch. It is a room full of people who think they missed their turn.
The obvious answer is a server. A WebSocket, a room, a broadcast. Maybe an afternoon of work — plus a host, plus a network you trust, plus a component that can be down while 500 people are standing in front of it.
The venue's wireless was the deciding constraint. And the two screens were three metres apart, on the same laptop. So the question got much smaller: how does one tab tell another one something?
The API almost nobody reaches for#
BroadcastChannel shipped in Firefox in 2015 and has been in every major
browser for years. It is three lines.
const channel = new BroadcastChannel("queue");
// The control panel publishes.
function callNext(state) {
channel.postMessage({ type: "state", state });
render(state);
}
// The display screen subscribes.
channel.onmessage = (event) => {
if (event.data.type === "state") render(event.data.state);
};Open a channel with a name. Anything you post goes to every other channel with that name, on the same origin, in the same browser profile — other tabs, other windows, iframes, workers.
There is no server, no connection to establish, no reconnection logic, no heartbeat. There is also nothing to go down.
Try it#
The two panels below each hold their own BroadcastChannel. This is not a
simulation: the spec delivers a message to every channel object of that name
except the one that posted it, and "except the one that posted it" is per
object, not per document. Two channels in one page talk to each other exactly
the way two tabs do.
Which means it also works across real tabs — open this page in a second one and drive it from here.
Two tabs, one queue — interactive, loads on scroll
Three things that will catch you out#
Your own tab does not hear you. The posting channel object is excluded. This
is sensible and it is also the first bug everyone writes: you post the new state,
you wait for your own handler to render it, and nothing happens. Note the
render(state) call sitting next to postMessage above — that is not
belt-and-braces, it is required.
A channel has no memory. It is a pipe, not a store. A tab that opens after the message went out has missed it permanently, and there is no replay, no backlog, no "give me the current value". This is the one that actually matters, and it is what the checkbox in the demo above is about.
There is no delivery guarantee worth relying on. No acknowledgements, no ordering guarantees across contexts, no way to know whether anyone was listening. For a display screen three metres away this is fine. For anything where a missed message is a correctness problem, it is not a message queue and should not be used as one.
The fix for the second one is boring#
State lives in localStorage. The channel only announces that it changed.
const KEY = "queue";
function publish(state) {
localStorage.setItem(KEY, JSON.stringify(state));
channel.postMessage({ type: "state", state });
}
// A screen that opens late reads; a screen already open gets told.
const initial = JSON.parse(localStorage.getItem(KEY) ?? "null");
render(initial ?? EMPTY);Two entry points, one rendering path. A tab that was already open takes the message; a tab that just opened reads the store. Neither knows which case it is in, which is the property you want.
This is the same split you would build on top of a socket — snapshot plus
subsequent events — just with the snapshot in storage instead of a GET /state.
What else you could have used#
| Approach | Server needed | Survives a late-opening tab | Cross-device |
|---|---|---|---|
BroadcastChannel | no | no, on its own | no |
storage events | no | via the same store | no |
SharedWorker | no | yes, worker holds state | no |
| WebSocket | yes | yes | yes |
storage events get you the same reach and are older, but they fire as a side
effect of writing to storage rather than as a message you meant to send — and
you cannot send anything that is not a state change. SharedWorker is the more
correct answer if you want a single owner of state, and it costs you Safari
history you would rather not research. A socket is the right call the moment the
screens stop being in the same building.
For two tabs on one laptop, BroadcastChannel is the smallest thing that works.
What it was actually like#
It ran for two days. Nothing went wrong, and the reason nothing went wrong is that there was nothing in it — no dependencies, no build step, no server, no network path. The failure modes were "the laptop dies" and "the browser crashes", and both recover in ten seconds by opening two tabs that read their state back out of storage.
A second school asked for it afterwards.
The full write-up — the constraints, what I turned down, and the thing I got wrong — is in the case study.