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.All from 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();
ConcernSymptomAddress it with
Messages missing on some instancesUsers see updates at randomA Redis backplane or Azure SignalR Service
Missed messages after a dropStale view after a reconnectResync on onreconnected
Connection count as the limitMemory growth per userSticky sessions and horizontal scale with a backplane
Long-running method never returnsA stuck circuitCancellation tokens and timeouts
Cross-tenant leakageUsers see another tenant's dataA group per tenant, never a global broadcast
Proxies closing idle connectionsConstant reconnectsKeep-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.

Interactive UI with Blazor Static files, uploads and streaming responses

Last refreshed 2026-09-18.