SignalR: Real-Time Communication for .NET Applications
A practical guide to ASP.NET Core SignalR — the library for adding real-time functionality like chat, live notifications, and live dashboards to .NET applications, covering hubs, transports, groups, scaling, authentication, and client integration.
Table of Contents
- Introduction
- Why SignalR Exists
- Transports and Connection Negotiation
- Hubs: The Core Abstraction
- Calling Clients from the Server
- Groups
- Strongly-Typed Hubs
- Client Integration
- Streaming
- Authentication and Authorization
- Scaling Out: The Backplane Problem
- Reconnection and Resilience
- SignalR vs. Raw WebSockets vs. gRPC Streaming
- Quick Reference Table
- Conclusion
Introduction
SignalR is a real-time communication library for ASP.NET Core that lets server code push content to connected clients instantly, as it happens — instead of clients repeatedly polling an endpoint asking "anything new?" It abstracts away the underlying transport (WebSockets, Server-Sent Events, or long polling) behind a simple, RPC-style programming model: the server calls a method on the client, and the client calls a method on the server, both as if invoking a local function.
// Server: push a message to every connected client
await hubContext.Clients.All.SendAsync("ReceiveMessage", user, message);
// Client: receive it
connection.on("ReceiveMessage", (user, message) => {
console.log(`${user}: ${message}`);
});
No manual WebSocket frame handling, no reconnect logic to write from scratch, no fallback strategy to design for clients or networks that don't support WebSockets — SignalR handles all of it.
1. Why SignalR Exists
The problem with polling
Before real-time libraries were common, "live" updates were usually faked with polling:
setInterval(async () => {
const response = await fetch('/api/notifications/latest');
// ... check if anything's new
}, 3000);
This wastes bandwidth and server resources on requests that usually return "nothing new," adds latency (up to the polling interval) before a client sees an update, and doesn't scale well as the number of connected clients grows — every client is hammering the server on a timer regardless of whether anything actually changed.
What SignalR provides instead
- Push-based updates — the server sends data the instant it's available, with no polling delay.
- Transport abstraction — SignalR automatically negotiates the best available transport (WebSockets, then falls back to Server-Sent Events, then long polling) based on what the client, server, and any intermediate proxies support.
- Connection management — automatic reconnection, connection lifecycle events, and a persistent connection ID per client.
- Grouping and targeting — send messages to all clients, a specific client, a named group, or a specific authenticated user, without managing that plumbing by hand.
- Scale-out support — a backplane (Redis, Azure SignalR Service) lets multiple server instances behind a load balancer act as one logical hub.
Typical use cases
- Chat applications — the canonical SignalR example.
- Live notifications — "your report is ready," "someone commented on your post."
- Live dashboards — stock tickers, monitoring dashboards, sports scores updating in real time.
- Collaborative editing indicators — "Alice is typing...", cursor positions in a shared document.
- Live progress reporting — long-running server-side jobs streaming progress updates back to the initiating client.
2. Transports and Connection Negotiation
SignalR doesn't require WebSockets specifically — it negotiates the best transport available and falls back gracefully:
| Transport | How it works | When it's used |
|---|---|---|
| WebSockets | Full-duplex, persistent TCP connection | Preferred whenever client, server, and any proxies support it |
| Server-Sent Events (SSE) | One-way server→client stream over HTTP, client sends separate HTTP requests | Fallback when WebSockets aren't available but the browser supports SSE |
| Long polling | Client makes a request that the server holds open until there's data, then immediately re-polls | Last-resort fallback for restrictive proxies/older environments |
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
});
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub", {
transport: signalR.HttpTransportType.WebSockets // force a specific transport if needed
})
.build();
In most modern deployments, WebSockets is negotiated and used automatically — the fallback chain exists mainly for older browsers, corporate proxies that block WebSocket upgrades, or specific hosting environments with limited support.
3. Hubs: The Core Abstraction
A Hub is SignalR's central abstraction — a class that both receives calls from clients and can be used to push calls back to them.
A minimal chat hub
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
}
-
SendMessageis a method the client calls (like an RPC endpoint). -
Clients.All.SendAsync("ReceiveMessage", ...)is how the server calls a method on connected clients —"ReceiveMessage"is just a string name the client subscribes to, not a real C# method (see Section 6 for a strongly-typed alternative).
Registration
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
var app = builder.Build();
app.MapHub<ChatHub>("/chatHub");
app.Run();
Connection lifecycle events
public class ChatHub : Hub
{
public override async Task OnConnectedAsync()
{
await Clients.Caller.SendAsync("ReceiveMessage", "System", "Welcome!");
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
await Clients.Others.SendAsync("ReceiveMessage", "System", $"{Context.ConnectionId} left.");
await base.OnDisconnectedAsync(exception);
}
}
Context.ConnectionId is a unique identifier SignalR assigns per connection — useful for tracking presence, mapping connections to users, or targeting specific clients.
4. Calling Clients from the Server
The Clients property on a Hub (or an injected IHubContext<T> outside a hub) exposes several targeting options:
await Clients.All.SendAsync("ReceiveMessage", user, message); // everyone connected
await Clients.Caller.SendAsync("ReceiveMessage", "System", "Hi!"); // just the caller
await Clients.Others.SendAsync("ReceiveMessage", user, message); // everyone except the caller
await Clients.Client(connectionId).SendAsync("Notify", "You have mail"); // one specific connection
await Clients.Clients(connectionIds).SendAsync("Notify", "Batch update"); // a specific set of connections
await Clients.User(userId).SendAsync("Notify", "Order shipped"); // all connections for one authenticated user
Calling from outside a Hub (e.g., from a controller or background service)
Often you need to push a message triggered by something that isn't itself a Hub method — a completed background job, an incoming webhook, a scheduled task. Inject IHubContext<T> for this:
public class OrderService
{
private readonly IHubContext<OrderHub> _hubContext;
public OrderService(IHubContext<OrderHub> hubContext) => _hubContext = hubContext;
public async Task MarkAsShippedAsync(int orderId)
{
// ... update the database
await _hubContext.Clients.User(orderOwnerId).SendAsync("OrderShipped", orderId);
}
}
This is the pattern for connecting SignalR to the rest of your application — most real-time notifications don't originate from a client calling a hub method, they originate from server-side business logic that needs to notify clients about something that just happened.
5. Groups
Groups let you organize connections and broadcast to a subset of clients — for example, everyone viewing a specific chat room or dashboard.
public class ChatHub : Hub
{
public async Task JoinRoom(string roomName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
await Clients.Group(roomName).SendAsync("ReceiveMessage", "System", $"{Context.ConnectionId} joined {roomName}");
}
public async Task LeaveRoom(string roomName)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
}
public async Task SendToRoom(string roomName, string user, string message)
{
await Clients.Group(roomName).SendAsync("ReceiveMessage", user, message);
}
}
Groups are managed entirely server-side and are not automatically restored across reconnects — if a client's connection drops and reconnects (getting a new ConnectionId), your application code needs to re-add it to any relevant groups, typically in OnConnectedAsync based on some persisted association (e.g., "this user was in room 42").
6. Strongly-Typed Hubs
Calling Clients.All.SendAsync("ReceiveMessage", user, message) with a string method name is stringly-typed — a typo in the method name is a runtime bug, not a compile-time error, and there's no IntelliSense for the client-side method's parameters. A strongly-typed hub fixes this using a shared interface:
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
Task UserJoined(string user);
}
public class ChatHub : Hub<IChatClient>
{
public async Task SendMessage(string user, string message)
{
await Clients.All.ReceiveMessage(user, message); // compiler-checked method call
}
}
public class NotificationService
{
private readonly IHubContext<ChatHub, IChatClient> _hubContext;
public NotificationService(IHubContext<ChatHub, IChatClient> hubContext) => _hubContext = hubContext;
public Task NotifyJoinedAsync(string user) =>
_hubContext.Clients.All.UserJoined(user);
}
This is the recommended approach for any non-trivial hub — it turns an entire class of "I renamed a method but forgot to update the string literal" bugs into compile-time errors.
7. Client Integration
JavaScript/TypeScript client
import * as signalR from "@microsoft/signalr";
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub")
.withAutomaticReconnect()
.build();
connection.on("ReceiveMessage", (user, message) => {
appendMessageToChat(user, message);
});
connection.on("UserJoined", (user) => {
appendSystemMessage(`${user} joined the chat.`);
});
await connection.start();
async function sendMessage(user, message) {
await connection.invoke("SendMessage", user, message);
}
.NET client (for server-to-server or desktop/mobile apps)
var connection = new HubConnectionBuilder()
.WithUrl("https://example.com/chatHub")
.WithAutomaticReconnect()
.Build();
connection.On<string, string>("ReceiveMessage", (user, message) =>
{
Console.WriteLine($"{user}: {message}");
});
await connection.StartAsync();
await connection.InvokeAsync("SendMessage", "Bot", "Hello from a .NET client!");
SignalR also has first-party clients for Java (Android) and via community/third-party support for other platforms, making it viable for cross-platform real-time apps beyond just web browsers.
Blazor Server: SignalR under the hood
If you've used Blazor Server, you've already used SignalR without necessarily realizing it — Blazor Server's entire UI update mechanism (sending rendered UI diffs to the browser and receiving DOM events back) runs over an automatically-managed SignalR connection.
8. Streaming
SignalR supports server-to-client streaming, useful for sending a large or ongoing sequence of results without buffering everything into one message first.
public class DataHub : Hub
{
public async IAsyncEnumerable<int> StreamCounter(
int count,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
for (int i = 0; i < count; i++)
{
await Task.Delay(1000, cancellationToken);
yield return i;
}
}
}
connection.stream("StreamCounter", 10)
.subscribe({
next: (item) => console.log("Received:", item),
complete: () => console.log("Stream complete"),
error: (err) => console.error(err),
});
Clients can also stream data to the server using an IAsyncEnumerable<T> or ChannelReader<T> parameter on a hub method — useful for scenarios like uploading a continuous sequence of sensor readings.
9. Authentication and Authorization
SignalR integrates directly with ASP.NET Core's authentication and authorization pipeline.
[Authorize]
public class ChatHub : Hub
{
public override async Task OnConnectedAsync()
{
var userName = Context.User?.Identity?.Name;
await Clients.Others.SendAsync("UserJoined", userName);
await base.OnConnectedAsync();
}
}
[Authorize(Roles = "Admin")]
public async Task BroadcastAdminMessage(string message)
{
await Clients.All.SendAsync("ReceiveMessage", "Admin", message);
}
Passing the access token for WebSocket connections
Browsers can't attach custom headers to a WebSocket handshake request, so SignalR's JS client passes the token via a query string parameter instead, and the server needs to be configured to read it from there for hub requests specifically:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(accessToken) &&
context.HttpContext.Request.Path.StartsWithSegments("/chatHub"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub", { accessTokenFactory: () => getAccessToken() })
.build();
10. Scaling Out: The Backplane Problem
By default, SignalR keeps connection state and group membership in the memory of a single server process. This works fine for one instance, but breaks down the moment you scale to multiple server instances behind a load balancer: if client A connects to server 1 and client B connects to server 2, a message sent to "all clients" from server 1's process has no way to reach client B — server 1 doesn't know server 2 even exists.
The fix: a backplane
A backplane is a shared message broker that all server instances subscribe to, so a message published by any one instance gets relayed to every instance, which then forwards it to its own locally-connected clients.
builder.Services.AddSignalR()
.AddStackExchangeRedis("localhost:6379", options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp");
});
Azure SignalR Service — a managed alternative
Rather than self-hosting Redis as a backplane, Azure SignalR Service is a fully managed alternative that takes over connection management entirely — clients connect to the Azure service directly instead of your app servers, and your app servers just publish messages to it and handle hub logic:
builder.Services.AddSignalR().AddAzureSignalR();
This removes the backplane concern entirely and also solves a related problem: since WebSocket connections are long-lived and sticky (a client needs to keep talking to the same server instance for the life of the connection), a managed service avoids needing sticky sessions configured at your load balancer — a real operational headache in self-hosted, multi-instance deployments.
11. Reconnection and Resilience
Automatic reconnection
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub")
.withAutomaticReconnect([0, 2000, 10000, 30000]) // retry delays in ms
.build();
connection.onreconnecting(error => {
showConnectionStatus("Reconnecting...");
});
connection.onreconnected(connectionId => {
showConnectionStatus("Connected");
// Re-join any groups/rooms — group membership does not survive a reconnect automatically
});
connection.onclose(error => {
showConnectionStatus("Disconnected — please refresh");
});
Handling missed messages
Because a reconnect gets a new ConnectionId, and SignalR doesn't buffer/replay messages sent while a client was disconnected, applications that can't tolerate gaps typically need their own strategy — e.g., including a sequence number or timestamp in messages, and having the client fetch anything it missed via a regular REST call immediately after reconnecting, rather than relying on SignalR alone for guaranteed delivery.
12. SignalR vs. Raw WebSockets vs. gRPC Streaming
| Aspect | Raw WebSockets | SignalR | gRPC Streaming |
|---|---|---|---|
| Transport fallback | None — WebSockets only | Automatic (WebSockets → SSE → long polling) | None — requires HTTP/2 |
| Browser support | Native, but you write all the framing/reconnect logic | First-class JS/TS client with built-in reconnection | Needs gRPC-Web, and browsers can't do full bidi streaming |
| Programming model | Raw message send/receive, you define the protocol | RPC-style method calls (Clients.All.SendAsync(...)) |
RPC-style, strongly-typed via protobuf |
| Scaling | Fully manual (you build your own backplane/pub-sub) | Built-in Redis/Azure SignalR Service backplane support | Typically handled at the infrastructure/mesh level |
| Best fit | You need full control and are building your own protocol | Web/mobile apps needing real-time features with minimal plumbing | Internal service-to-service streaming, not browser-facing |
Practical guidance
- Building a chat feature, live notifications, or a live dashboard for a web app? → SignalR — it's specifically built for this and handles transport fallback, reconnection, and scaling concerns you'd otherwise have to build yourself.
- Need bidirectional streaming between internal backend services? → gRPC streaming (see the gRPC guide) is the better fit; it's not meant for browsers.
- Need something extremely low-level and custom, with total control over the wire protocol? → Raw WebSockets, accepting that you'll build reconnection, fallback, and scaling logic yourself.
Quick Reference Table
| Concept | Purpose |
|---|---|
| Hub | Central class for server↔client real-time RPC-style calls |
Clients.All/.Caller/.Others/.User()
|
Targeting options for pushing messages to clients |
| Groups | Named subsets of connections for scoped broadcasts |
Hub<T> / strongly-typed hubs |
Compile-time-checked client method calls |
IHubContext<T> |
Push messages to clients from outside a hub (services, controllers) |
Streaming (IAsyncEnumerable<T>) |
Server-to-client (or client-to-server) streamed data |
| Backplane (Redis) | Synchronizes messages across multiple self-hosted server instances |
| Azure SignalR Service | Managed alternative removing backplane/sticky-session concerns |
withAutomaticReconnect() |
Client-side automatic reconnection with configurable retry delays |
access_token query param |
How JWTs are passed for WebSocket connections (headers aren't available) |
Conclusion
SignalR takes one of the more genuinely painful areas of web development — real-time, bidirectional communication with graceful fallback, reconnection, and scale-out — and reduces it to a simple RPC-style programming model: call a method on the server, call a method on the client, and let the library handle transports, connection lifecycle, and (with a backplane or Azure SignalR Service) multi-instance scaling.
It's not the right tool for every real-time need — internal service-to-service streaming is usually better served by gRPC, and situations needing a fully custom wire protocol may call for raw WebSockets — but for the common case of "push updates from an ASP.NET Core backend to browsers or .NET clients," SignalR remains the most direct and well-supported path, and it's worth reaching for before hand-rolling WebSocket handling from scratch.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the reconnection edge case that taught you to respect the backplane.
Top comments (0)