Skip to content

Shipped

A queue for 500 people, built in an afternoon

A recruitment fair needed a control panel and a display screen that never disagreed. The venue's network was not to be trusted, so nothing was allowed to leave the laptop.

Role
Solo build
Timeline
May 2026
Stack
HTMLCSSVanilla JSBroadcastChannellocalStorage

Context#

NASA International Schools in Jeddah ran a two-day recruitment fair. Something on the order of 500 people came through it, each waiting for an interview slot.

Two audiences, two screens. Staff needed a control panel: call the next number, skip whoever did not turn up, undo the number they had just called by mistake. Candidates needed a display screen in the waiting area showing the current number and a rough sense of how long the wait was, in Arabic and English.

The two screens had to agree. Always. The failure mode is not a cosmetic glitch — it is a room full of people who believe they have missed their turn.

Constraints#

  • The venue's network. Anything that had to leave the laptop was a liability, and the one thing guaranteed to be busy on the day was the wireless.
  • No budget and no lead time. This was not a procurement exercise. It was a thing that needed to exist before Monday.
  • Nobody to operate it but the staff running the fair. Whatever it was, it had to be obvious to someone who had never seen it, under pressure, with a queue in front of them.
  • Two languages. Announcements had to read naturally in Arabic and English.

Decisions#

No server#

The reflexive answer is a WebSocket. A small server, a room, a broadcast — an afternoon of work and a thing you understand well.

It also adds a host, a network path, and a component that can be down while 500 people are standing in front of it. Every one of those is a new way for the day to go wrong, and none of them buys anything the event actually needs: the two screens are three metres apart, on the same laptop.

So the two screens became two tabs of the same browser, and the question turned into something much smaller — how does one tab tell another one something?

What I turned down: a WebSocket server. It is the right answer if the screens are in different buildings. They were not.

BroadcastChannel, which had been sitting there since 2015#

Same-origin tabs can talk directly. One line to open a channel, one to publish, one to subscribe:

queue-channel.js
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);
};

The publishing tab does not receive its own message — which is why callNext renders locally as well as posting. That asymmetry is the one thing about the API that will catch you out.

What I turned down: localStorage events as the transport. They work, they are older, and they are a side effect of a storage write rather than a message bus. BroadcastChannel says what it means.

State in storage, changes on the channel#

A channel is a pipe, not a store. Open the display screen after the control panel has called number 14 and it knows nothing — it was not listening when the message went out.

So the current state lives in localStorage and the channel only announces that it changed. A screen that opens late reads the state; a screen already open gets told. Same rendering path, two entry points.

You can watch that failure happen here — close the display screen, call a few numbers, and reopen it:

Two tabs, one queue — interactive, loads on scroll

No framework, no build step#

The whole thing is three files that a browser opens directly. There is nothing to install on the day, nothing to compile, and nothing that expires. If the laptop had died, the recovery procedure was to open two tabs on a different one.

What I turned down: a small React app. It would have been more pleasant to write and it would have added a build step to a thing whose main virtue is that it has no moving parts.

Architecture#

A control panel tab posts state to a BroadcastChannel, which delivers it to the display screen tab. The control panel also writes the current state to localStorage, which the display screen reads when it opens.postMessageonmessagewritesread on openControl paneltab 1BroadcastChannelsame originDisplay screentab 2localStoragecurrent state
The channel carries changes. Storage carries the current state, for whoever arrives late.

Outcome#

  • Ran the queue for a two-day fair with roughly 500 candidates.
  • Zero dependencies, zero build step, no server. Three files.
  • About two hours, built and tested.
  • MIS International Schools in Jeddah asked for it afterwards and ran it at their own event.

The last one is the number I care about. Nobody adopts a tool they had to be talked into.

What I'd do differently#

I would have persisted the history, not just the current number.

The undo stack lived in memory. That was a deliberate call at the time — undo is a within-the-minute affordance, and the current number was the thing that absolutely could not be lost. It is also why, at the end of two days, the system could tell you it was on number 340 and nothing whatsoever about how it got there.

That history was the interesting data. How long people actually waited, where the day stalled, whether the skip button got used the way I assumed it would — all of it existed for a moment and then did not. The second school ran the same tool without the benefit of anything the first two days had learned, and there was no technical reason for that. Writing the log to the same localStorage I was already writing to would have cost a line.