This is the payoff part. Since the spec, one sentence has been carrying this whole project: "a waiting-room screen that updates by itself." Today we build it — kiosk check-in, a public board, a staff queue — and meet the ideas that make real-time apps boring in the best way: a thin hub, groups, automatic reconnects, and the discipline of notify, don't ship state.
Follow along: git checkout part-07 in
the companion repo; the commit message is the
prompt, as always.
Blazor Server was already real-time
Here's the thing Part 5 planted: every interactive Blazor Server page already holds a live SignalR connection — the circuit. Every button click you've made in this app so far travelled over a WebSocket. So why are we adding our own hub? Because the circuit is a private line between one browser tab and its own components. What the queue needs is a broadcast: one check-in at the kiosk has to reach the board on the wall, the staff queue at reception, and any screen that shows up later. A hub is that fan-out — a channel any client can subscribe to, beyond the borders of a single circuit. (This distinction becomes the whole plot of Part 8, so file it away.)
Model pick: Opus, high effort. Hubs, groups and reconnect semantics are exactly where you want the thinking model: the failure modes (missed messages, stale state, silent disconnects) are invisible in a quick demo and expensive to discover later. This is the opposite call from Part 6's CRUD — and making that call per-task is the skill.
The prompt
Verbatim, from the commit "Add the live queue: kiosk check-in, waiting-room board, staff queue":
Now the real-time part. A QueueHub at /hubs/queue that carries ONE
signal — 'QueueChanged' — and a comment explaining the rule: notify,
don't ship state; clients re-query the database. QueueService:
CheckInAsync by confirmation code (today only, friendly errors for
unknown/already/cancelled codes), GetSnapshotAsync (now-serving +
waiting list), CallNextAsync (finish whoever is in progress, call the
next waiting patient). Pages: /kiosk (big code input, giant friendly
text), /board (public waiting-room screen: first name + last initial
ONLY — it hangs on a wall; plus a live/reconnecting dot), /staff/queue
behind [Authorize] with a Call-next button. Board and staff pages
subscribe with HubConnection + WithAutomaticReconnect and re-join the
group on Reconnected.
The thin hub
The hub the prompt demanded is eleven lines, and that's the point:
/// <summary>
/// The queue hub is deliberately "thin": it carries a single signal — QueueChanged —
/// and clients re-query the database for fresh state. Notify, don't ship state:
/// no stale payloads, no ordering races, one source of truth.
/// </summary>
public class QueueHub : Hub
{
public const string BoardGroup = "board";
public Task JoinBoard() => Groups.AddToGroupAsync(Context.ConnectionId, BoardGroup);
}
The tempting alternative is a "fat" hub that broadcasts the whole queue snapshot in every message — it saves clients a query, so it looks efficient. It's a trap, twice over. Stale payloads: the snapshot you serialize describes the queue as it was when you built the message; by the time it renders on the wall, a second check-in may already have happened. Ordering races: two broadcasts can arrive out of order, and "apply the last payload received" quietly means "display the older state". A thin signal has neither problem: the message carries no state to be stale, and every recipient answers it by asking the database — the one source of truth — for now. The cost is a re-query per event, which for a clinic-sized queue rounds to nothing.
On the service side, every mutation ends the same way — CheckInAsync and
CallNextAsync both finish with one line:
private Task BroadcastChangeAsync() =>
hub.Clients.Group(QueueHub.BoardGroup).SendAsync("QueueChanged");
Groups, reconnects, and an honest dot
Board.razor is where the client-side pieces meet. It opens a HubConnection, asks
for automatic reconnection, re-queries on every signal — and re-joins the group after a reconnect:
_hub = new HubConnectionBuilder()
.WithUrl(Navigation.ToAbsoluteUri("/hubs/queue"))
.WithAutomaticReconnect()
.Build();
_hub.On("QueueChanged", async () =>
{
_snapshot = await Queue.GetSnapshotAsync();
await InvokeAsync(StateHasChanged);
});
_hub.Reconnected += async _ =>
{
await _hub.InvokeAsync(nameof(ClinicLive.Hubs.QueueHub.JoinBoard));
_connected = true;
await InvokeAsync(StateHasChanged);
};
_hub.Closed += async _ =>
{
_connected = false;
await InvokeAsync(StateHasChanged);
};
await _hub.StartAsync();
await _hub.InvokeAsync(nameof(ClinicLive.Hubs.QueueHub.JoinBoard));
The Reconnected handler is the line reviews skip and production regrets. Group membership
belongs to a connection ID, and a reconnect mints a new one — so a board that doesn't re-join after
reconnecting sits there looking alive while receiving nothing. That's also why the prompt demanded the dot:
a screen that hangs on a wall for eight hours must confess when it's stale.
<p class="mt-4 small @(_connected ? "text-success" : "text-danger")">
● @(_connected ? "live" : "reconnecting…")
</p>
Kill the server mid-demo and the dot turns red with "reconnecting…"; bring it back and the board re-joins, re-queries and goes green — no refresh, no stale list.
The verified moment
The commit records the test that made this part real: three browser surfaces open at once — kiosk, board, staff queue. Type a confirmation code into the kiosk, and the name appeared on the untouched board tab within a second. Press "Call next" on the staff queue, and the patient moved to "Now serving" on both screens simultaneously. No polling, no refresh button, one broadcast. If you've never built real-time before, do this with your own hands at the checkpoint — it's the moment the architecture diagram from Part 5 stops being a diagram.
Privacy on a wall
One more beat from the prompt that deserves its own heading: the board is a public surface in a physical room. Full names on a waiting-room screen are a privacy leak with a bench in front of it. The service masks at the source, so no page can accidentally do the wrong thing:
/// <summary>The waiting-room board is public — first name and last initial only.</summary>
private static QueueItem ToItem(QueueEntry q)
{
var parts = q.Appointment.Patient.FullName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var display = parts.Length > 1 ? $"{parts[0]} {parts[^1][0]}." : parts[0];
return new QueueItem(q.AppointmentId, display, q.Appointment.StartsAt, q.CheckedInAt);
}
"Maria G." tells Maria it's her turn and tells everyone else nothing. Constraints like this rarely come from an AI unprompted — they come from imagining the room the software runs in.
What the AI got wrong: a compile error with a lesson in it. It injected
QueueService Queue into Queue.razor — and the compiler answered with
CS0542: a member can't share the name of its enclosing type. A page called
Queue.razor is a class named Queue, so the injected property needed a
different name (QueueSvc). Trivial fix, useful pattern: remember it. And one more thing —
the demo queue looked perfectly ordered today, with patients called in the order they checked in. It
looked fine. Keep this part's GetSnapshotAsync in mind;
Part 10 is coming.
The meter: Opus is the expensive seat, and this part used it properly — hub design, three new surfaces, reconnect semantics and the review conversation added about $0.90, for a running total of ≈ $2.70. Still less than a coffee run for a live three-screen system.
Checkpoint: three tabs open — /kiosk, /board,
/staff/queue (logged in). A check-in at the kiosk appears on the board within a second;
"Call next" updates both screens; stopping the server flips the board's dot to red and restarting flips
it back to green with fresh data. The board never shows more than a first name and an initial.
The queue proved that our own hub can push updates to anonymous screens. The obvious next step — staff chat —
looks like the same trick with an [Authorize] attribute on it. It is not, and the way it fails
is the single most instructive moment in this series:
Part 8: chat, presence, and the 401 that explains Blazor
Server.