Real-time features with SignalR
Hubs, groups and reconnection, and the scaling decision that has to be made before the first user connects, not after.
Hubs and clients
public sealed class OrderHub : Hub<IOrderClient>
{
private readonly ILogger<OrderHub> _log;
public OrderHub(ILogger<OrderHub> log) => _log = log;
public override async Task OnConnectedAsync()
{
var tenant = Context.User?.FindFirst("tenant")?.Value;
if (tenant is null)
{
Context.Abort(); // never serve a connection without a tenant
return;
}
// A group per tenant, so broadcasts never cross a boundary
await Groups.AddToGroupAsync(Context.ConnectionId, "tenant:" + tenant);
await base.OnConnectedAsync();
}
public override Task OnDisconnectedAsync(Exception? exception)
{
if (exception is not null)
_log.LogWarning(exception, "hub connection {ConnectionId} failed",
Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
// Called by a client
public Task SubscribeToOrder(int orderId) =>
Groups.AddToGroupAsync(Context.ConnectionId, "order:" + orderId);
// Called from server code elsewhere in the application
public static Task NotifyAsync(IHubContext<OrderHub, IOrderClient> hub,
string tenant, int orderId, string status,
CancellationToken ct = default) =>
hub.Clients.Group("tenant:" + tenant)
.OrderChanged(orderId, status);
// Always take a cancellation token on a long-running hub method
public async Task<IReadOnlyList<Order>> LoadRecentAsync(CancellationToken ct)
{
await Task.Delay(50, ct); // stand-in for a repository call
return Array.Empty<Order>();
}
}
// A typed client interface keeps the method names in one place
public interface IOrderClient
{
Task OrderChanged(int orderId, string status);
Task Notification(string message);
}- Use a strongly typed hub. A typo in a client method name is otherwise a silent no-op at runtime.
- Every method that may run long needs a
CancellationToken: the framework passes one that fires when the connection drops. - Authorise inside the hub method as well as at the connection. A connection being authenticated says nothing about which orders it may subscribe to.
- Messaging a group is cheap; iterating
Clients.Allfrom application code is a pattern that does not scale.
Connections, reconnection and scale-out
// A browser client with automatic reconnect and a visible state
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/orders")
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
.configureLogging(signalR.LogLevel.Warning)
.build();
connection.on("OrderChanged", (orderId, status) => {
renderUpdate(orderId, status);
});
connection.onreconnecting(() => setStatus("reconnecting"));
connection.onreconnected(() => {
setStatus("connected");
resync(); // the client missed messages while disconnected
});
connection.onclose(() => setStatus("offline"));
await connection.start();// Scale out: without a backplane, messages only reach clients on one instance
builder.Services.AddSignalR()
.AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis"), o =>
{
o.Configuration.ChannelPrefix = RedisChannel.Literal("orders");
});
// Also raise the per-message limit when clients send large payloads
builder.Services.AddSignalR(o => o.MaximumReceiveMessageSize = 64 * 1024);
app.MapHub<OrderHub>("/hubs/orders").RequireAuthorization();| Concern | Symptom | Address it with |
|---|---|---|
| Messages missing on some instances | Users see updates at random | A Redis backplane or Azure SignalR Service |
| Missed messages after a drop | Stale view after a reconnect | Resync on onreconnected |
| Connection count as the limit | Memory growth per user | Sticky sessions and horizontal scale with a backplane |
| Long-running method never returns | A stuck circuit | Cancellation tokens and timeouts |
| Cross-tenant leakage | Users see another tenant's data | A group per tenant, never a global broadcast |
| Proxies closing idle connections | Constant reconnects | Keep-alive settings on the proxy |
💡
Real-time is a delivery hint, not a source of truth. Treat a push as a signal to refetch, so a missed message degrades to a slightly stale view rather than a permanently wrong one — and so a reconnect needs no complex replay logic.
FAQ
SignalR or raw WebSockets?
SignalR unless you must interoperate with a non-.NET client that already speaks a fixed WebSocket protocol. SignalR gives you transport fallback, groups, reconnection and typed calls for free.
Why do messages only reach some users behind a load balancer?
Because each request may land on a different instance and the group membership lives in memory. A backplane or a managed service is required, and sticky sessions help the connection itself stay on one node.
Related
Interactive UI with Blazor Static files, uploads and streaming responses
Last refreshed 2026-09-18.