The Push Channel Nobody Plugged In
DShop runs a whole SignalR service to push operation results to the browser in real time - JWT handshake, per-user groups, Redis backplane. No frontend connects to it, and both UIs sleep through eventual consistency instead.
The 202 contract left a question hanging: you POST an order, get a 202 and an operation id, and then — what? How does the browser learn the order was approved without hammering the status endpoint in a loop? DShop has a complete, thoughtful answer to that question. There is a whole service devoted to it: DShop.Services.Signalr, which pushes operation results to the browser in real time over a WebSocket, with a JWT handshake, per-user targeting, and a Redis backplane for horizontal scale. It is real, and it is well-built. It is also connected to nothing. No frontend opens the socket, one of the three events it broadcasts is never even published, and both UIs solve the problem the service exists to solve by sleeping for a second. This part follows that dead channel end to end, because a whole real-time subsystem that shipped unused is a rare and instructive thing to read.
The push path, built properly
Start at the hub. DShopHub is a SignalR Hub with a real authentication handshake — the client calls InitializeAsync with a token, and the hub validates it before letting the connection into anything:
public async Task InitializeAsync(string token)
{
if (string.IsNullOrWhiteSpace(token))
await DisconnectAsync();
try
{
var payload = _jwtHandler.GetTokenPayload(token);
if (payload == null) { await DisconnectAsync(); return; }
var group = Guid.Parse(payload.Subject).ToUserGroup(); // "users:{id}"
await Groups.AddToGroupAsync(Context.ConnectionId, group);
await ConnectAsync();
}
catch { await DisconnectAsync(); }
}
The connection joins a group named users:{userId} derived from the token's subject, so a push can be aimed at exactly one user's browsers. The fan-out wrapper does precisely that:
public async Task PublishToUserAsync(Guid userId, string message, object data)
=> await _hubContext.Clients.Group(userId.ToUserGroup()).SendAsync(message, data);
And the service is wired into the bus. Its Startup subscribes to the three operation events and, when the backplane is configured as Redis, registers the Redis backplane so the hub scales across instances:
app.UseRabbitMq()
.SubscribeEvent<OperationPending>(@namespace: "operations")
.SubscribeEvent<OperationCompleted>(@namespace: "operations")
.SubscribeEvent<OperationRejected>(@namespace: "operations");
An OperationUpdatedHandler implements all three IEventHandler<> interfaces and forwards each event to the hub as operation_pending, operation_completed, or operation_rejected. This is the correct design: the Operations orchestrator watches the saga, emits an operation event when work finishes, and the SignalR service bridges that bus event onto a WebSocket aimed at the one user who is waiting. Bus-to-socket bridging with per-user groups and a backplane — this is how real-time-over-events is supposed to look.
The handshake even closes its own loops. On a valid token the hub calls back SendAsync("connected") to the client; on any failure — empty token, unparseable payload, an exception — it calls SendAsync("disconnected") and drops the connection. A client would know, on connect, whether it was authenticated and in its group. The one thing the hub does not do is reconnect or fall back: there is no retry policy, no long-poll degradation, no offline queue for a push that arrives while the socket is down. In 2018 that was par — automatic reconnection was a client-library concern that barely existed yet — but it means the channel, even if a client existed, would be a best-effort one: miss the window and you miss the event, with nothing to replay it.
Two ways it never fires
Now the deflation, in two parts, both verified in source.
One of the three events is never published. The Operations service has an OperationPublisher with three methods — PendingAsync, CompleteAsync, RejectAsync — and only two of them are ever called. Reading every call site of the publisher, CompleteAsync and RejectAsync fire from the generic event handler when a saga finishes or is rejected; PendingAsync is called from nowhere:
switch (@event)
{
case IRejectedEvent rejectedEvent:
await _operationPublisher.RejectAsync(context, /* ... */);
return;
case IEvent _:
await _operationPublisher.CompleteAsync(context);
return;
}
There is no branch that calls PendingAsync. So OperationPending is a fully-defined event — a message class, a publisher method, a subscription in the SignalR service, a PublishOperationPendingAsync fan-out, an operation_pending client message — that nothing ever emits. The receiver is listening for a signal the transmitter was never wired to send.
No client ever connects. Even the two events that do publish — completed and rejected — arrive at a hub with no audience. Grep both frontends for any SignalR client — @aspnet/signalr in the Angular app, a HubConnection in the Blazor app — and there is nothing. Neither frontend opens the socket, calls InitializeAsync, or listens for operation_completed. The hub broadcasts to users:{id} groups that no browser has ever joined. The whole channel is a radio station transmitting to a town with no radios.
What the frontends do instead
So how do the UIs cope with the eventual-consistency gap that the SignalR service was built to close? They sleep. Here is the Blazor order flow, in the component that runs after you place or complete an order:
// OrderDetailsComponent / CartDetailsComponent
await _ordersService.CreateAsync();
await Task.Delay(1000);
// ...then navigate / refresh
await Task.Delay(1000) — wait one second and hope the saga finished. Both the cart and order components do it. It is the exact anti-pattern the push channel exists to eliminate: instead of being told when the operation completes, the UI guesses a duration long enough that it probably has. If the saga takes 1,100 milliseconds, the user sees stale data; if it takes 50, the user waited 950ms for nothing. The service that would have replaced this guess with a fact is running, subscribed, and ignored.
The honest ledger
To be fair to DShop: the SignalR service is genuinely the best-designed real-time component I've read from this era. The JWT-on-connect handshake, the per-user group targeting, the optional Redis backplane, the clean bus-to-socket bridge — this is a correct blueprint, and a reader can learn the right shape of push-over-events from it directly. It is also a teaching estate, and it is entirely plausible the SignalR service and the Task.Delay frontends were built by different people at different times as separate lessons, never meant to meet. The parts are good; only the wiring between them is missing.
But the missing wiring is the whole lesson, and it is a lesson about how distributed systems rot at the seams rather than in the components. Every piece of DShop's real-time story works in isolation and the story does not exist, because “does each part work?” and “is the system connected?” are different questions and only the first one has a unit test. The publisher compiles with an uncalled method. The subscriber compiles with no publisher. The frontend compiles with a Task.Delay where a subscription belongs. Nothing is broken; nothing is plugged in. When you inherit a distributed estate, the components will each look fine — the bugs live in the wires between them, and the wires are exactly what no single repository's tests can see.
Next, we turn to those frontends directly and read them as a matched pair: two frontends, one API.