SignalR
Real-time web in ASP.NET Core -- hubs, transport negotiation and fallback, groups and per-user messaging, strongly-typed hubs, and scaling out with a backplane
SignalR
SignalR is ASP.NET Core's real-time library, and its central trick is that it abstracts the transport entirely. It negotiates a WebSocket first, falls back to Server-Sent Events, then to long polling, and switches automatically when a proxy, load balancer, or corporate firewall blocks the upgrade. You never touch a socket -- you write hub methods and call connected clients as if they were local objects, and the framework moves the bytes. That abstraction is the whole point and also the whole catch: SignalR is a SignalR-protocol-to-SignalR-client system. You talk to it with its own JS/TS, .NET, or Java client, not a raw WebSocket. That makes it ideal for first-party apps where you control both ends, and a poor fit for a public, transport-agnostic API where third parties expect to connect with whatever they have.
Hubs
The Hub is the core abstraction: an RPC endpoint where the server calls methods on connected clients and clients call methods on the server. You write methods that look like ordinary async server code; SignalR wires the routing, serialization, and dispatch underneath. A chat hub with room-based broadcast is the canonical shape -- a client joins a group, and any message sent to that group fans out to everyone in it.
public class ChatHub : Hub
{
public async Task SendToRoom(string room, string message) =>
await Clients.Group(room).SendAsync("message", message);
public async Task JoinRoom(string room) =>
await Groups.AddToGroupAsync(Context.ConnectionId, room);
}
// Program.cs
builder.Services.AddSignalR();
app.MapHub<ChatHub>("/hub/chat");The Clients property is how you address the client side, and the named targets are the vocabulary of every hub: Clients.All reaches every connection, Clients.Caller only the one that invoked the method, Clients.Others everyone except the caller, Clients.Group(name) a named group, Clients.User(id) every connection of a logged-in user, and Clients.Client(id) a single connection. Context.ConnectionId identifies the current connection, and you override OnConnectedAsync and OnDisconnectedAsync to run setup and teardown -- registering presence, restoring group membership, cleaning up state -- as connections come and go. Treat those lifecycle hooks as the only reliable place to react to a client appearing or vanishing.
Addressing: Connections, Groups, and Users
There are three addressing scopes, and getting them straight saves a lot of confusion. A connection is the finest grain: Context.ConnectionId is one ephemeral connection, and a single user with three browser tabs has three connection IDs that change on every reconnect. A group is a named bucket with dynamic membership that you manage by hand -- chat rooms, document-editing sessions, a live dashboard's subscribers. You add and remove connections explicitly with Groups.AddToGroupAsync and Groups.RemoveFromGroupAsync. A user is the coarsest grain: Clients.User(userId) targets every connection a logged-in user currently holds, across devices and tabs at once, which is exactly what you want for "notify this person" regardless of where they are.
The user identifier comes from the authenticated ClaimsPrincipal -- by default the name identifier claim -- and you can override the mapping by implementing IUserIdProvider when your notion of identity differs from the default claim. The one thing to burn into memory is that groups are not persisted. Group membership lives in server memory, tied to live connections. When a client reconnects -- new connection ID -- it is in no groups at all, and nothing re-adds it for you. Re-establishing membership on reconnect is your job, not the framework's.
Strongly-Typed Hubs
The base Hub addresses clients with magic strings -- SendAsync("message", ...) -- which the compiler cannot check and a typo silently breaks. Hub<T>, where T is a client interface, fixes that. You declare the methods the client implements as an interface, and the hub calls them as real method calls with compile-time checking on the name and the argument types.
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
}
public class ChatHub : Hub<IChatClient>
{
public Task SendToRoom(string room, string user, string message) =>
Clients.Group(room).ReceiveMessage(user, message);
}Make this the default for any non-trivial hub. The interface doubles as living documentation of the server-to-client contract, refactoring tools rename across it, and you lose an entire category of "the client method name didn't match" bugs that are otherwise invisible until runtime. The only thing it does not give you is type safety across the wire to a non-.NET client -- the TypeScript side still registers handlers by string -- but on the server, where the broadcasts live, it is strictly better.
The Client
On the browser you use @microsoft/signalr. You build a connection with HubConnectionBuilder, point it at the hub URL, register handlers for the methods the server will call with .on(...), start the connection, and invoke server methods with .invoke(...). The withAutomaticReconnect() call is worth adding by default: it retries the connection with a backoff schedule when the transport drops, which on real networks it will.
const conn = new signalR.HubConnectionBuilder()
.withUrl("/hub/chat")
.withAutomaticReconnect()
.build();
conn.on("message", (msg) => render(msg));
await conn.start();
await conn.invoke("JoinRoom", "general");The .NET client is symmetric -- HubConnection built the same way -- which makes service-to-service real-time, desktop, or mobile clients straightforward when both ends are .NET. The mental model is identical across clients: register what the server can call, start, then invoke what you can call on the server.
Authentication
Hubs honor [Authorize] exactly like controllers, and the authenticated ClaimsPrincipal is what Clients.User(...) and Context.User read from. The access token rides along on the negotiate request and the connection. The standard gotcha is the WebSocket handshake: browsers cannot set arbitrary headers on it, so you cannot send a normal Authorization: Bearer header the way you would for a fetch. The JS client supplies the token through accessTokenFactory, and the server is configured to read the token from the query string for the hub path.
const conn = new signalR.HubConnectionBuilder()
.withUrl("/hub/chat", { accessTokenFactory: () => getToken() })
.build();This trips up almost everyone the first time auth works on every controller but fails on the hub. The fix is small but non-obvious, and it has to be done on both ends: the client passes accessTokenFactory, and the JWT bearer setup wires the token out of the query string when the request targets the hub path.
Scaling Out: The Backplane
This is the production concern that the single-server demo hides. Each SignalR server holds its connections in its own memory. The moment you run more than one instance, a user connected to server A and a user connected to server B are invisible to each other -- a broadcast on A never reaches anyone on B, because B does not know those connections exist. A backplane fixes this by propagating every hub message across all instances, so a message published on any server fans out to clients on every server.
builder.Services.AddSignalR()
.AddStackExchangeRedis("redis:6379");Redis is the common backplane: each server publishes hub invocations to a Redis channel and subscribes to the others, so Redis pub/sub becomes the fan-out fabric. Azure SignalR Service goes further -- it offloads the persistent connections themselves, so the app servers no longer hold sockets and stay effectively stateless, with Azure managing the connection pool. For NZ teams already on Azure that is a natural fit, since it removes the backplane wiring and the sticky-session problem in one move. Note that sticky sessions (session affinity at the load balancer) are still required for some transports -- because the negotiate step and the connection can land on different servers -- unless you use Azure SignalR or explicitly skip the negotiation step.
Streaming
Hubs are not limited to one-shot calls. A hub method that returns IAsyncEnumerable<T> streams results from server to client as they are produced, and a method that accepts a ChannelReader<T> streams from client to server. This suits progressive results -- live log tailing, a large query yielding rows over time, incremental render data -- where you want the client to start consuming before the whole result exists.
public async IAsyncEnumerable<string> StreamLogs(
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var line in _logSource.ReadAsync(ct))
yield return line;
}The cancellation token matters: when the client unsubscribes or disconnects, the token is cancelled, and honoring it is what stops the server from producing into a connection nobody is reading. Flow it through every await, exactly as you would in any other long-running .NET method.
Sharp Edges
The recurring failures cluster around state and scale. Group and connection state is lost on every reconnect -- a new connection ID is in no groups, so re-join in the reconnect handler or users silently fall out of their rooms. The backplane is mandatory the instant you run more than one instance; the classic incident is a single-server demo that works perfectly and then breaks the day it scales out, because messages no longer reach clients on other servers. Messages have size limits and the pipeline applies backpressure, so streaming firehoses of data without bounds will stall or drop. Sticky sessions are required for some transports unless you move to Azure SignalR. The auth token travels via the query string on the hub path, which surprises everyone whose controllers authenticate fine while the hub rejects them. And the structural cost underneath all of it: SignalR holds a live connection -- and the memory backing it -- per connected client, which is the same stateful-scaling bill as any WebSocket server. Plan capacity around concurrent connections, not requests per second.