DEV Community

Cover image for SignalR: Real-Time Communication for .NET Applications
Rhuturaj Takle
Rhuturaj Takle

Posted on

SignalR: Real-Time Communication for .NET Applications

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

  1. Introduction
  2. Why SignalR Exists
  3. Transports and Connection Negotiation
  4. Hubs: The Core Abstraction
  5. Calling Clients from the Server
  6. Groups
  7. Strongly-Typed Hubs
  8. Client Integration
  9. Streaming
  10. Authentication and Authorization
  11. Scaling Out: The Backplane Problem
  12. Reconnection and Resilience
  13. SignalR vs. Raw WebSockets vs. gRPC Streaming
  14. Quick Reference Table
  15. 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);
Enter fullscreen mode Exit fullscreen mode
// Client: receive it
connection.on("ReceiveMessage", (user, message) => {
    console.log(`${user}: ${message}`);
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chatHub", {
        transport: signalR.HttpTransportType.WebSockets // force a specific transport if needed
    })
    .build();
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • SendMessage is 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();
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode
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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

.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!");
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
connection.stream("StreamCounter", 10)
    .subscribe({
        next: (item) => console.log("Received:", item),
        complete: () => console.log("Stream complete"),
        error: (err) => console.error(err),
    });
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode
[Authorize(Roles = "Admin")]
public async Task BroadcastAdminMessage(string message)
{
    await Clients.All.SendAsync("ReceiveMessage", "Admin", message);
}
Enter fullscreen mode Exit fullscreen mode

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;
            }
        };
    });
Enter fullscreen mode Exit fullscreen mode
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chatHub", { accessTokenFactory: () => getAccessToken() })
    .build();
Enter fullscreen mode Exit fullscreen mode

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");
    });
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

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)