Blazor Server + SignalR
Real-time server-rendered UI with Blazor Server over a SignalR circuit — how the persistent connection works, the Redis backplane for scale-out, and when to choose SignalR over raw WebSockets.
Everything in Appendix C used raw WebSockets — manual binary frames, hand-built backplanes, explicit sequence tracking. That is the right choice for a polyglot system. But when the entire stack is .NET, SignalR abstracts all of that away, and Blazor Server builds a full server-rendered UI on top of it. This appendix covers both — what they are, how they scale, and when to reach for them instead of raw sockets.
What Blazor Server is
Blazor Server is a server-rendered component model where the browser runs a thin JavaScript runtime (the SignalR client) and all C# logic stays server-side. The browser and the server maintain a persistent SignalR circuit — a single WebSocket per connected client — and the server sends DOM diffs over that circuit whenever the UI changes. The browser applies the diff; the user sees a live, interactive page without downloading a heavy client-side framework.
The trade-off is latency: every click is a round-trip to the server, so users on high-latency connections notice a delay. For internal dashboards, admin panels, and tools where the server is close, this is invisible. For consumer-facing apps on mobile networks, consider Blazor WebAssembly (runs in the browser) or a traditional SPA instead.
SignalR under the hood
SignalR is ASP.NET Core’s real-time abstraction. At its core it is a Hub — a class with methods the client can call, and methods the server can push to connected clients. The transport is a WebSocket by default, with automatic fallback to Server-Sent Events or long polling when WebSockets aren’t available.
// OrderHub.cs — a SignalR hub that pushes order events to connected clients
public class OrderHub : Hub
{
public async Task JoinTenantGroup(string tenantId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, tenantId);
}
public async Task BroadcastOrder(OrderDto order)
{
await Clients.Group(order.TenantId).SendAsync("OrderPlaced", order);
}
}
Three features distinguish SignalR from raw WebSockets:
- Strongly typed methods — the server calls
Clients.All.SendAsync("OrderPlaced", order)and the client receives a typed callback, not a raw byte buffer. No manual serialization, no type discriminators, noswitchon message type. - Connection groups —
Groups.AddToGroupAsyncreplaces the per-pod registry and backplane fan-out from Appendix C. One API call adds a client to a logical group; SignalR handles the routing. - Automatic reconnection — the client SDK reconnects with configurable backoff and restores group memberships automatically.
Scaling: the same problem, a built-in solution
Blazor Server and SignalR face the same per-pod pinning problem as raw WebSockets (Appendix C, Figure C.1): each SignalR circuit is pinned to the pod that accepted it, scale-up leaves new pods idle, and scale-down drops active circuits.
The solution is the same — a shared backplane — but SignalR builds it in:
// Program.cs — wire the Redis backplane for multi-pod scale-out
builder.Services.AddSignalR()
.AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!,
options => options.Configuration.ChannelPrefix =
RedisChannel.Literal("cndp-signalr"));
One line replaces the manual Redis pub/sub subscription, the per-topic fan-out,
and the per-pod connection registry from Appendix C. Under the hood, SignalR
publishes group messages to Redis channels and each pod’s subscriber delivers
them to the local circuits. The ChannelPrefix isolates this app’s traffic from
other SignalR apps sharing the same Redis instance.
For teams that want fully managed infrastructure, Azure SignalR Service offloads the persistent connections entirely: the app server handles Hub logic but the connections terminate at Azure, removing the per-pod connection limit from your scaling equation.
A Blazor Server order dashboard
Here is a minimal but complete example: a Blazor Server page that shows orders in real time. When a new order is placed via the REST API, the hub pushes it to every connected dashboard.
// Pages/Dashboard.razor — live-updating order list via SignalR
@page "/dashboard"
@inject NavigationManager Nav
@implements IAsyncDisposable
<h3>Live Orders</h3>
<table class="table">
<thead><tr><th>ID</th><th>SKU</th><th>Qty</th><th>Status</th></tr></thead>
<tbody>
@foreach (var o in orders)
{
<tr><td>@o.Id</td><td>@o.Sku</td><td>@o.Quantity</td><td>@o.Status</td></tr>
}
</tbody>
</table>
@code {
private HubConnection? hub;
private readonly List<OrderDto> orders = [];
protected override async Task OnInitializedAsync()
{
hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/hubs/orders"))
.WithAutomaticReconnect()
.Build();
hub.On<OrderDto>("OrderPlaced", order =>
{
orders.Insert(0, order);
InvokeAsync(StateHasChanged);
});
await hub.StartAsync();
}
public async ValueTask DisposeAsync()
{
if (hub is not null) await hub.DisposeAsync();
}
}
The component subscribes to the hub on mount, inserts each new order at the top of
the list, and calls StateHasChanged to trigger a re-render — which sends a DOM
diff (one new <tr>) over the circuit. No polling, no manual WebSocket code, no
binary framing.
When to choose what
| Criterion | Raw WebSockets (Appendix C) | SignalR | Blazor Server |
|---|---|---|---|
| Languages | Any (cross-language) | .NET server, JS/C#/.NET client | .NET only |
| Wire format | Binary protobuf (you own it) | MessagePack or JSON (SignalR owns it) | DOM diffs (Blazor owns it) |
| Backplane | Hand-built (Redis pub/sub, Kafka) | Built-in (AddStackExchangeRedis) |
Inherits from SignalR |
| Reconnection | Hand-built (backoff + seq resume) | Built-in (WithAutomaticReconnect) |
Built-in (circuit recovery) |
| Latency | Minimal (raw frames) | Low (hub invocation overhead) | Round-trip per interaction |
| Use case | Polyglot, low-latency, custom protocols | .NET real-time APIs, chat, notifications | Internal dashboards, admin panels |
The rule of thumb: raw WebSockets when you need cross-language support or control over the wire format. SignalR when the server is .NET and you want connection management handled for you. Blazor Server when you also want the UI rendered server-side, avoiding a separate frontend framework entirely.
Cross-check it yourself
Run the Blazor Server example, open the dashboard in two browser tabs, and place an
order via curl. Both tabs should update simultaneously — confirming the SignalR hub
push and the Redis backplane fan-out. Then stop and restart one of the Blazor Server
pods and confirm the circuit reconnects automatically without losing the dashboard
state.
The code is in examples/30-blazor-signalr/. The run script there builds and
runs it; its README.md covers what it does and how to drive it.
Verification status: verified — examples/30-blazor-signalr/ passes 10/10 checks on Docker Engine (2026-10-10): health, order placement and listing, the dashboard page, the SignalR negotiate endpoint, a second dashboard instance, Redis backplane delivery in both directions between the two instances (a SignalR client on one receives an order posted to the other), orders listed across instances, and backplane delivery to a restarted instance (the script asserts the container’s start time changed and it returned healthy). Blazor circuit reconnection is the framework default and is not covered by the script.