Skip to content
.N
.NET // September 22, 2026 · 9 min read

How SignalR Enables Real-Time Features in ASP.NET Core Applications

LM
Liam Mercer
// contributor
How SignalR Enables Real-Time Features in ASP.NET Core Applications

A user watching a live dashboard shouldn't have to refresh the page to see new data. A chat window shouldn't lag behind what the other person just typed. Yet a huge share of "real-time" features in production web apps are still built on polling — a client asking a server "anything new?" every few seconds, most of the time getting back a shrug. SignalR exists to replace that pattern with something closer to a standing connection: the server pushes data the moment it has something worth sending, and the client just listens.

How SignalR Enables Real-Time Features in ASP.NET Core Applications

For ASP.NET Core developers, SignalR is the default answer to "how do I make this live." But the library hides a fair amount of complexity behind a deceptively simple API, and most of the production issues teams run into — dropped connections, messages that don't reach every client, servers that fall over under concurrent load — trace back to skipping past that complexity during the prototype phase. This piece covers how SignalR actually works, how to choose a transport, and what changes once an app needs more than one server.

Why Polling Breaks Down for Live-Updating Interfaces

Polling is easy to reason about and easy to implement, which is exactly why so many features start there. A client fires an HTTP request on an interval, the server responds with whatever's changed, and the loop repeats. The problem isn't that polling doesn't work — it's that it works badly at both ends of the spectrum.

Poll too infrequently and updates feel sluggish; a five-second interval means a five-second worst-case delay on every piece of new information. Poll too aggressively and the server absorbs a constant stream of requests that mostly return nothing, because most polling intervals land between actual changes. At 1,000 concurrent users polling every two seconds, that's 500 requests per second hitting an endpoint that, most of the time, has nothing new to report. The server is doing real work — authentication, routing, database round-trips in the worst implementations — to answer "no."

SignalR flips the direction of that relationship. Instead of the client repeatedly asking, the connection stays open and the server sends data down it only when there's something to send. The practical effect is that update latency drops to roughly the time it takes to push a message over an open connection — typically well under a second — while the steady-state load on the server drops to near zero when nothing is happening, since idle connections cost far less than repeated request/response cycles.

How SignalR Hubs Work Under the Hood

The core abstraction in SignalR is the hub — a server-side class that acts as a high-level RPC endpoint over a persistent connection. Instead of thinking in terms of HTTP verbs and routes, you think in terms of methods clients can call on the server, and methods the server can call on clients.

csharp

public class DashboardHub : Hub
{
    public async Task SendMetricUpdate(string metricName, double value)
    {
        await Clients.All.SendAsync("ReceiveMetricUpdate", metricName, value);
    }
}

On the client side (JavaScript, in this example), the connection is established once and stays open:

javascript

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/dashboardHub")
    .build();

connection.on("ReceiveMetricUpdate", (metricName, value) => {
    updateChart(metricName, value);
});

await connection.start();

What makes this useful in practice is the Clients abstraction. Clients.All broadcasts to every connected client, but SignalR also supports Clients.Group("region-eu") for broadcasting to a subset, and Clients.User(userId) for targeting a specific authenticated user across all of their open connections (a user can easily have three: a laptop tab, a phone, a second monitor). Groups are managed server-side with Groups.AddToGroupAsync, typically called from OnConnectedAsync based on route data, claims, or a query parameter — this is how a dashboard can push metrics only to clients viewing a specific region or account, rather than broadcasting everything to everyone and filtering client-side, which wastes bandwidth and leaks data to clients that shouldn't see it.

Connections are tracked by a ConnectionId, which is not the same as a user identity — a single logged-in user can hold multiple connection IDs simultaneously. Conflating the two is a common source of bugs where a message reaches one of a user's tabs but not another.

How SignalR Enables Real-Time Features in ASP.NET Core Applications

Choosing a Transport: WebSockets, SSE and Long Polling

SignalR doesn't commit to a single transport protocol. Instead, it negotiates the best available option between client and server, falling back gracefully when something in the network path doesn't support the preferred choice.

WebSockets is the preferred transport wherever available: a single TCP connection, upgraded from HTTP, that supports full-duplex communication — both sides can send at any time without waiting for the other. This is the right fit for anything bidirectional, like collaborative editing or chat, where the client also needs to push data, not just receive it.

Server-Sent Events (SSE) is the fallback when WebSockets aren't available but the browser supports streaming responses. SSE is unidirectional — server to client only — which is a real limitation for chat-style features but is often plenty for dashboards, notifications, and live scoreboards, where the client rarely needs to talk back over the same channel.

Long polling is the last resort: a client sends a request, the server holds it open until there's data (or a timeout), responds, and the client immediately opens a new request. It works almost anywhere, including through restrictive corporate proxies that block WebSocket upgrades, but it carries meaningfully higher latency and server overhead than the other two options.

In practice, most teams don't manually select a transport — SignalR negotiates it automatically and downgrades transparently. The exception is when a load balancer or reverse proxy sits in front of the app and doesn't have WebSocket support configured; in that case, connections silently downgrade to long polling and nobody notices until someone asks why a "real-time" feature feels a beat slow. Checking WebSocket support at the proxy layer (in IIS, this means the WebSocket Protocol feature; in nginx, proxy_set_header Upgrade and Connection headers) is one of the more common early-deployment fixes.

Scaling SignalR Across Multiple Servers With a Backplane

A single server handling SignalR connections is straightforward: all connections live in that process's memory, and broadcasting a message just means iterating over them. The moment an app scales to more than one server instance — which is the normal case for anything under real load — that model breaks, because a message sent from Server A has no way to reach a client whose connection happens to be held open on Server B.

This is where a backplane comes in. A backplane is a shared message bus that every server instance subscribes to; when one instance needs to broadcast, it publishes to the backplane, and every other instance relays the message to its own locally connected clients. The two common production options are:

  • Redis backplane — SignalR ships first-party support for using Redis pub/sub as the backplane, via the Microsoft.AspNetCore.SignalR.StackExchangeRedis package. This is the standard self-hosted choice and works well for most workloads.

  • Azure SignalR Service — a fully managed alternative that moves connection management out of your own servers entirely, which also sidesteps the WebSocket-proxy configuration issue mentioned above, since the managed service handles the client connections directly.

One detail that trips teams up during scale-out: if you're using sticky sessions (routing a client back to the same server on every request) as a workaround instead of a proper backplane, you've effectively capped your real-time reliability at "works until that server restarts or the load balancer reroutes traffic," at which point every connection pinned to it drops. A backplane isn't optional once you're running more than one instance — it's the mechanism that makes horizontal scaling actually work for persistent connections rather than just working around it.

Where Real-Time UI Updates Show Up Across the Web

The pattern described above — a persistent connection, a server pushing updates the moment something changes, a client rendering that update without a page reload — is not specific to dashboards or chat. It's the same underlying requirement behind live sports scores, collaborative document editors, stock tickers, multiplayer game state, and any interface where a number or status on screen needs to change without user action. Consumer platforms that display frequently changing data to logged-in users — live scoreboards, chat products, social and real-money gaming interfaces such as Crazytower official — all sit in this same broad category of "UI that must reflect server-side state changes within a second or two," even though the specific transport, framework, and infrastructure each one runs on is a separate, unrelated question from the general pattern being described here.

What differs across these use cases isn't the underlying transport-layer problem — it's the fan-out pattern. A live sports score broadcasts identically to every viewer. A chat message goes to a small group. A per-user account balance or notification goes to exactly one connection. Recognizing which fan-out shape a feature needs, before writing the hub, avoids a common redesign: building everything on Clients.All and then retrofitting groups once it turns out half the traffic shouldn't have been broadcast that widely in the first place.

Common Pitfalls When Moving From Prototype to Production

A SignalR feature that works cleanly against one developer's laptop can behave very differently under concurrent load, and most of the gap comes down to a handful of recurring mistakes.

Treating hub methods like stateless HTTP actions. A hub instance is created per invocation, but the underlying connection persists — state that needs to survive between method calls (like a user's group membership) has to live somewhere external to the hub itself, such as a distributed cache, not in instance fields.

Skipping reconnection handling. Network blips happen constantly on mobile connections. SignalR's client supports automatic reconnection (withAutomaticReconnect()), but without it, a dropped WiFi signal for two seconds silently kills the "real-time" feature until the user manually refreshes.

No backpressure strategy for high-frequency updates. If a server-side event fires 50 times a second and each one triggers a broadcast, clients on slower connections fall behind and buffer indefinitely. Throttling or debouncing updates server-side, rather than assuming every client can keep pace, prevents this from becoming a memory problem.

Forgetting authorization on hub methods. Hubs support the same [Authorize] attribute as controllers, but it's easy to wire up a hub, get it working, and forget that anyone who discovers the endpoint URL can connect and start listening unless authorization is explicitly enforced.

None of these are exotic problems — they're the same class of issue that shows up in any system built around persistent, stateful connections rather than the stateless request/response model most ASP.NET Core code is written around. Treating a hub as "just another controller" is the fastest way to end up debugging why

More from Dot Net Masters