SignalR is the library ASP.NET Core ships for pushing data from server to client the moment something happens, instead of making clients poll for it. Dashboards, chat, live notifications and collaborative editing all fit this shape: many connected clients, a server that knows when something changed, and a need to deliver that change in milliseconds rather than on the next poll. This guide covers how SignalR picks a transport, how to design hubs that scale, groups and per-user targeting, streaming, authentication, the MessagePack protocol, and the two ways to scale a hub across multiple servers.

What Is ASP.NET Core SignalR?#

SignalR is a real-time messaging library built into ASP.NET Core. A hub is the server-side endpoint: a class with methods clients can call, and a way for the server to call methods on connected clients in return. Underneath, SignalR negotiates a transport, picks a wire protocol, and manages connection identity, groups and reconnection so you write plain C# methods instead of hand-rolling any of that.

It is not a replacement for REST APIs or gRPC; it solves a different problem. REST and gRPC are for a client asking for something. SignalR is for the server telling clients something changed without being asked, over a connection that stays open.

How SignalR Works: Hubs, Transports and Protocols#

SignalR supports three transports and picks the best one both sides can use: WebSockets, Server-Sent Events and Long Polling. WebSockets is preferred whenever both the client and the server support it, because it gives a single, full-duplex connection with the least overhead; Server-Sent Events and Long Polling are fallbacks for environments that block WebSockets, such as some corporate proxies. Negotiation happens automatically on connection start, so application code targets the hub, not a specific transport.

On top of the transport, a hub protocol defines how method calls are serialized. JSON is the default and is human-readable, which helps while debugging; MessagePack is a binary alternative covered later in this guide, chosen when payload size or serialization speed matters more than readability.

TransportDirectionTypical use
WebSocketsFull duplexPreferred whenever available
Server-Sent EventsServer to client, with a separate HTTP request for client-to-server callsBrowsers/proxies that block WebSockets but allow persistent HTTP
Long PollingSimulated duplex over repeated HTTP requestsLast-resort fallback; highest latency and overhead

Getting Started: Your First Hub#

A hub method is just an async method other code can call by name from a client:

C#
// Hubs/ChatHub.cs
using Microsoft.AspNetCore.SignalR;

namespace Contoso.Chat.Hubs;

public sealed class ChatHub : Hub
{
    public async Task SendMessage(string user, string message)
    {
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}
C#
// Program.cs
using Contoso.Chat.Hubs;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
builder.Services.AddCors(o => o.AddPolicy("client", p => p
    .WithOrigins("https://chat.contoso.com")
    .AllowAnyHeader()
    .AllowAnyMethod()
    .AllowCredentials()));

var app = builder.Build();

app.UseCors("client");
app.MapHub<ChatHub>("/hubs/chat");

app.Run();

The JavaScript client connects, listens for server-invoked methods and invokes hub methods in return:

JavaScript
import * as signalR from "@microsoft/signalr";

const connection = new signalR.HubConnectionBuilder()
    .withUrl("https://api.contoso.com/hubs/chat")
    .build();

connection.on("ReceiveMessage", (user, message) => {
    console.log(`${user}: ${message}`);
});

await connection.start();
await connection.invoke("SendMessage", "alice", "Deploy finished.");

Clients.All reaches every connected client. Most real apps narrow that down with groups or specific users, covered next.

Strongly Typed Hubs with Hub&lt;T&gt;#

Clients.All.SendAsync("ReceiveMessage", ...) compiles even if the method name is misspelled or an argument type is wrong, because client calls are just strings and object[] under the hood. Hub<T> fixes that by describing the client's callable methods as an interface:

C#
public interface IChatClient
{
    Task ReceiveMessage(string user, string message);
    Task UserTyping(string user);
}

public sealed class ChatHub : Hub<IChatClient>
{
    public async Task SendMessage(string user, string message)
    {
        await Clients.All.ReceiveMessage(user, message);
    }

    public async Task NotifyTyping(string user)
    {
        await Clients.Others.UserTyping(user);
    }
}

Hub<T> disables the string-based Clients.All.SendAsync overload entirely for that hub, so a renamed or retyped client method is a compile error on the server instead of a silent runtime mismatch.

Groups and Targeting Users#

Groups are named sets of connections that a hub manages explicitly β€” there is no server-side concept of a group beyond what your code adds connections to:

C#
public sealed class ChatHub : Hub<IChatClient>
{
    public async Task JoinRoom(string roomName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
        await Clients.Group(roomName).ReceiveMessage("system", $"A user joined {roomName}.");
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        // SignalR removes the connection from its groups automatically on disconnect;
        // this override is only needed for extra cleanup, such as presence tracking.
        await base.OnDisconnectedAsync(exception);
    }
}

Users are different: Clients.User(userId) reaches every connection (potentially several, across tabs and devices) that SignalR associates with a given user identifier. By default that identifier comes from the ClaimTypes.NameIdentifier claim on the connection's ClaimsPrincipal; implement IUserIdProvider to use a different claim, such as an email address or a tenant-qualified user ID:

C#
public sealed class EmailUserIdProvider : IUserIdProvider
{
    public string? GetUserId(HubConnectionContext connection) =>
        connection.User?.FindFirst(ClaimTypes.Email)?.Value;
}

// Program.cs
builder.Services.AddSingleton<IUserIdProvider, EmailUserIdProvider>();

Streaming Data To and From a Hub#

A hub method can stream results to the client instead of returning one value, by returning IAsyncEnumerable<T> (or ChannelReader<T>):

C#
public sealed class MetricsHub : Hub
{
    public async IAsyncEnumerable<int> StreamCpuLoad(
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            yield return GetCurrentCpuPercent();
            await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
        }
    }
}
JavaScript
connection.stream("StreamCpuLoad")
    .subscribe({
        next: (value) => updateChart(value),
        error: (err) => console.error(err),
        complete: () => console.log("stream finished"),
    });

The cancellation token fires when the client calls .dispose() on the stream or disconnects, so long-running loops stop promptly instead of leaking. Client-to-server streaming works the same way in reverse: a hub method that accepts an IAsyncEnumerable<T> (or ChannelReader<T>) parameter receives items the client pushes with connection.send("UploadReadings", stream), which suits telemetry or file-chunk upload scenarios.

Client SDKs: JavaScript, .NET and Java#

SignalR ships first-party clients for JavaScript/TypeScript (browsers and Node.js), .NET (for server-to-server and desktop/MAUI scenarios) and Java (for Android and JVM backends). All three expose the same core model β€” connect, on for server-to-client handlers, invoke or send for client-to-server calls β€” with idioms matching their platform.

C#
// .NET client, e.g. from a background worker or another service
var connection = new HubConnectionBuilder()
    .WithUrl("https://api.contoso.com/hubs/chat")
    .WithAutomaticReconnect()
    .Build();

connection.On<string, string>("ReceiveMessage", (user, message) =>
    Console.WriteLine($"{user}: {message}"));

await connection.StartAsync();
await connection.InvokeAsync("SendMessage", "worker", "Batch job complete.");

Because the .NET client speaks the same protocol as the browser client, it is a natural way for one backend service to subscribe to another's hub β€” for example, a worker service that relays hub events into a message queue β€” without going through a browser at all.

Authentication and Authorization#

Hubs use the same [Authorize] attribute as controllers and Minimal APIs, at the hub level or per method:

C#
[Authorize]
public sealed class ChatHub : Hub<IChatClient>
{
    [Authorize(Policy = "Moderators")]
    public Task BanUser(string userName) => Task.CompletedTask;
}

The wrinkle is how the token gets there. Browsers cannot attach a custom Authorization header to a WebSocket or Server-Sent Events handshake, so SignalR's JavaScript client sends the access token as an access_token query string parameter for those transports instead; the .NET and Java clients send it as a normal header. Configure JWT bearer authentication to read it from either place:

C#
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;
                if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
                {
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });

Because OnMessageReceived runs before every request SignalR's long-lived connection makes, this same hook is also where you would reject or refresh a token for a connection that has been open for a while.

Automatic Reconnection and Connection Lifecycle#

Network blips, proxy timeouts and server restarts are normal for a connection meant to stay open for a session. withAutomaticReconnect() tells the client to retry instead of surfacing a dead connection immediately:

JavaScript
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat")
    .withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
    .build();

connection.onreconnecting(error => setStatus("reconnecting"));
connection.onreconnected(connectionId => {
    setStatus("connected");
    rejoinCurrentRoom(connectionId);
});
connection.onclose(error => setStatus("disconnected"));

A reconnect gets a new connection ID, so any server-side state keyed by the old one β€” group membership included β€” needs to be rejoined explicitly by the client after onreconnected fires; SignalR does not remember which groups a now-replaced connection used to belong to. The .NET client exposes the equivalent WithAutomaticReconnect(), Reconnecting, Reconnected and Closed events. Check connection.state (a HubConnectionState value: Disconnected, Connecting, Connected, Disconnecting or Reconnecting) before invoking a method from code that might run during a reconnect window.

The MessagePack Protocol#

JSON is readable but verbose. For high-frequency updates β€” a live market feed, a multiplayer game's position updates β€” MessagePack's binary encoding cuts payload size and parsing cost. Add it on the server and matching clients:

C#
// Program.cs
builder.Services.AddSignalR().AddMessagePackProtocol();
JavaScript
// npm install @microsoft/signalr-protocol-msgpack
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat")
    .withHubProtocol(new signalR.protocols.msgpack.MessagePackHubProtocol())
    .build();

Adding MessagePack does not remove JSON support; a hub happily serves both kinds of clients at once, since the protocol is negotiated per connection. Two details catch teams out: MessagePack does not preserve a DateTime's Kind, so convert to UTC before sending and back after receiving, and it is stricter about types than JSON β€” a value that does not match the expected type throws instead of silently converting.

Scaling Out: Redis Backplane and Azure SignalR Service#

A single server holds every WebSocket connection in memory, so Clients.All and Clients.Group only reach clients connected to that server. Scaling to more than one server needs a way to fan a message out across all of them, and SignalR has two supported options.

A Redis backplane uses Redis pub/sub: every server subscribes, and a message sent from any one of them is published to Redis and delivered to clients on every server. It requires sticky sessions (routing a client back to the same server for the lifetime of its connection) in most configurations, and every server still holds a share of the actual WebSocket connections, so you scale out based on connection count even for a chatty-but-few-clients workload.

Azure SignalR Service takes a different shape: clients connect to the managed service instead of directly to your servers, and your servers hold a small, constant number of connections to the service itself. That removes the sticky-session requirement and changes what you scale for β€” message volume rather than connection count β€” which is why it is the recommended default for SignalR apps hosted on Azure.

Redis backplaneAzure SignalR Service
Client connects toYour app server directlyThe managed service
Sticky sessions requiredUsually yesNo
Scales withNumber of concurrent connectionsMessage volume
Operational overheadYou run and monitor RedisFully managed
Good fitSelf-hosted or non-Azure deployments already running RedisASP.NET Core apps hosted on Azure

A gateway such as YARP can also provide the sticky routing a Redis backplane needs, by hashing on a cookie or client identifier to a consistent backend instance.

Testing and Monitoring SignalR Apps#

For integration tests, host the app in-memory with WebApplicationFactory and connect a real HubConnection to it over the test server's handler, which exercises the actual hub, authentication and serialization pipeline instead of mocking them:

C#
public class ChatHubTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public ChatHubTests(WebApplicationFactory<Program> factory) => _factory = factory;

    [Fact]
    public async Task SendMessage_broadcasts_to_other_clients()
    {
        var client = _factory.Server.CreateHandler();
        var connection = new HubConnectionBuilder()
            .WithUrl("http://localhost/hubs/chat", o => o.HttpMessageHandlerFactory = _ => client)
            .Build();

        var received = new TaskCompletionSource<string>();
        connection.On<string, string>("ReceiveMessage", (_, message) => received.SetResult(message));

        await connection.StartAsync();
        await connection.InvokeAsync("SendMessage", "test-user", "hello");

        Assert.Equal("hello", await received.Task.WaitAsync(TimeSpan.FromSeconds(5)));
    }
}

For production monitoring, watch connection count, the message rate and reconnect frequency per server (or per Azure SignalR Service unit), since a rising reconnect rate is usually the first sign of a network or proxy problem before users start complaining. Combine hub-level logging (ILogger<ChatHub> injected like any other dependency) with your existing ASP.NET Core observability setup so hub activity shows up next to the rest of the request pipeline rather than in a separate silo.

Best Practices#

  • Use Hub<T> for any hub with more than one or two client methods, so a rename or a typo fails the build instead of failing silently in production.
  • Key group membership off data you can recompute, such as a room ID from the connection's claims, so a client can safely rejoin its groups after every reconnect.
  • Choose Azure SignalR Service by default on Azure, and fall back to a self-managed Redis backplane only when you have a specific reason not to use it.
  • Enable MessagePack for high-frequency, latency-sensitive hubs, and leave JSON alone for low-traffic ones where readability during debugging is worth more than the bytes saved.
  • Always configure withAutomaticReconnect/WithAutomaticReconnect with explicit retry delays rather than relying on the default, so you can tune it against your load balancer's idle timeout.

Common Pitfalls#

  • Assuming a reconnect keeps group membership. It gets a new connection ID; rejoin groups explicitly in the client's onreconnected handler.
  • Sending the JWT as a header for browser transports. WebSockets and Server-Sent Events need it in the access_token query string instead.
  • Scaling to multiple servers without a backplane, which silently drops messages between clients connected to different instances.
  • Calling blocking code inside a hub method, which ties up the connection and, at scale, the thread pool; keep hub methods async all the way down.
  • Forgetting sticky sessions with a Redis backplane, which causes intermittent "missing" messages that are hard to reproduce locally with a single server instance.

SignalR vs Alternatives#

SignalRgRPC streamingRaw WebSockets
Transport negotiationAutomatic, with fallbacksHTTP/2 onlyManual
Browser supportFirst-classNeeds gRPC-WebNative
Groups, users, reconnectionBuilt inBuild it yourselfBuild it yourself
Cross-platform clientsJS, .NET, JavaAny gRPC-supported languageAny WebSocket client
Best fitBrowser-facing real-time featuresService-to-service streamingFull control over a custom protocol

Choose gRPC for service-to-service streaming where every endpoint is under your control and you want a strict contract; choose SignalR when browsers are a first-class client and you want groups, users, reconnection and transport fallback handled for you. Building directly on WebSockets only pays off when you need a protocol SignalR cannot express.

Frequently Asked Questions#

Does SignalR always use WebSockets?#

No. It negotiates the best transport both client and server support, preferring WebSockets, and falls back to Server-Sent Events and then Long Polling when WebSockets is unavailable, typically because of a restrictive proxy or an older client. Application code targets the hub the same way regardless of which transport was chosen.

How do I send a message to one specific user instead of everyone?#

Call Clients.User(userId), where userId matches whatever IUserIdProvider returns for that connection β€” by default, the ClaimTypes.NameIdentifier claim. This reaches every active connection for that user, which matters for users with multiple tabs or devices open at once.

Why do I need a backplane to scale SignalR across servers?#

Each server only knows about the WebSocket connections it is holding. Without a backplane, Clients.All or Clients.Group only reaches clients connected to the server handling that call, so a client on a different server never receives the message. A Redis backplane or Azure SignalR Service fans messages out to every server so all connected clients receive them regardless of which server they landed on.

Should I use JSON or MessagePack?#

Start with JSON; it is the default, debuggable in the browser network tab, and fast enough for most applications. Switch to MessagePack for high-frequency updates where payload size or serialization cost is measurably a bottleneck, keeping in mind its stricter type handling and lack of DateTime.Kind preservation.

Can a .NET backend service connect to another service's SignalR hub?#

Yes. The .NET client (HubConnectionBuilder) speaks the same protocol as the browser client, so one service can subscribe to another's hub the same way a browser would β€” useful for relaying hub events into a queue, a cache, or another downstream system.

Summary#

  • SignalR negotiates WebSockets, Server-Sent Events or Long Polling automatically, and JSON or MessagePack for the wire protocol.
  • Hub<T> gives compile-time safety for client calls; groups and Clients.User handle targeting subsets of connections.
  • Streaming works in both directions through IAsyncEnumerable<T> or ChannelReader<T>.
  • Authenticate hubs with [Authorize] as usual, but read browser tokens from the access_token query string via JwtBearerEvents.OnMessageReceived.
  • Scale out with a Redis backplane or, preferably on Azure, with Azure SignalR Service, and always rejoin groups after a reconnect.

Further Reading#