SignalR

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

SignalR is the real-time library of ASP.NET Core. It gives a .NET server and its clients a remote-procedure-call model on top of a persistent connection: the client calls named methods on a hub running on the server, and the server calls named methods on one client, a group of clients, or everybody. It negotiates a transport (WebSockets, then Server-Sent Events, then long polling), keeps the connection alive, reconnects, and, when you add a backplane, spreads the work over several servers.

This page was checked on 2026-10-24 against ASP.NET Core 10.0.13 (the current LTS release, package Microsoft.AspNetCore.SignalR.StackExchangeRedis 10.0.13), the JavaScript client @microsoft/signalr 10.0.11 and the Azure SDK Microsoft.Azure.SignalR 1.33.1. .NET 11 is at release candidate 2 (11.0.0-rc.2 packages) and is mentioned where it changes behaviour. The runnable example uses the mcr.microsoft.com/dotnet/sdk:10.0 and mcr.microsoft.com/dotnet/aspnet:10.0 images and redis:8.8.3-alpine.

This site already has two short overviews of the library: SignalR on ASP.NET Core (a tour of hubs, Clients.*, transports and the two clients) and SignalR 2 (the .NET Framework library, which has a different protocol and API). This page does not repeat them. It goes into what they leave out: the wire format, the connection lifecycle, the operational limits, security, scale-out, and a complete Chatterbox server that you can run.

Where SignalR fits

SignalR is a framework, not a protocol you speak by hand. That is its main strength and its main constraint.

  • Chatterbox (chat). SignalR suits it well when the whole stack is .NET: groups model rooms, Clients.User reaches every tab of one person, OnDisconnectedAsync drives presence, and the JavaScript, .NET, Java and Swift clients all exist. What it cannot do is speak the plain WebSocket envelope protocol defined in Message Design and Serialization, because every frame is wrapped in the SignalR hub protocol. The example below solves that by carrying Chatterbox envelopes as hub payloads.

  • Huddle (videoconference). SignalR is a convenient signalling channel: offers, answers and ICE candidates are small messages routed to one user or one group. The media never touches SignalR. See Signaling and Perfect Negotiation.

  • Pixel Arena (game). SignalR is a poor fit for a fast tick loop: every message goes through hub dispatch, JSON or MessagePack encoding and a per-connection ordering queue. It is acceptable for lobbies and turn-based play, and the game loop itself should use raw WebSockets or a data channel. See Multiplayer Game Architecture.

Choose SignalR when you want productivity inside a .NET system and can accept its own protocol on the wire. Choose a plain WebSocket endpoint when other languages must connect with a stock WebSocket client, or when you need the exact frame control of the WebSocket protocol. The comparison with the other stacks is in Choosing a WebSocket Stack.

The wire view

A SignalR connection starts with an HTTP request, not a WebSocket upgrade, and only then becomes a stream of hub-protocol messages.

Negotiate and transports

The client first sends POST /hubs/chatterbox/negotiate?negotiateVersion=1. The server answers with the connection identifiers and the transports it offers. This is the real answer of the example server below:

{
  "negotiateVersion": 1,
  "connectionId": "u9ERHh2zG5tOpKxOZ6Yuuw",
  "connectionToken": "qipgrRO-KDlZFlgRX-RfJQ",
  "availableTransports": [
    { "transport": "WebSockets", "transferFormats": ["Text", "Binary"] },
    { "transport": "ServerSentEvents", "transferFormats": ["Text"] },
    { "transport": "LongPolling", "transferFormats": ["Text", "Binary"] }
  ]
}

The client then opens the preferred transport with the token in the query string: a WebSocket to /hubs/chatterbox?id=<connectionToken>, an event stream plus separate POSTs for Server-Sent Events, or repeated GETs for long polling. The connectionId is what server code sees (Context.ConnectionId); the connectionToken is the secret the transport needs, and it is deliberately not exposed by any server API. The browser-side mechanics of the fallbacks are explained in Server-Sent Events and Long Polling.

Two consequences matter in production. First, the negotiate request and the transport request must reach the same server process, so a farm of servers needs sticky sessions, unless every client uses WebSockets only and sets skipNegotiation: true. Second, the negotiate request is an ordinary HTTP request, so it is where cookies, CORS and Authorization headers apply; the browser WebSocket API cannot send headers, which is why tokens travel as the access_token query-string parameter (see Authentication and authorization).

The hub protocol

Once the transport is open, the client sends a handshake naming the hub protocol, and the server confirms it. Everything afterwards is a sequence of hub-protocol messages. With the JSON protocol, each message is a JSON object followed by the ASCII record separator 0x1E, written below as \u001e. This session is captured from the example server with a raw WebSocket client, sending the Chatterbox hello envelope as the argument of a hub invocation:

<- {}\u001e                                       (after the client sent {"protocol":"json","version":1}\u001e)
-> {"type":1,"target":"Frame","arguments":[{"v":1,"type":"hello","id":"01JCCCCCCCCCCCCCCCCCCCCCCC",
    "room":null,"ts":0,"payload":{"token":"3256e58b4a07281cfa9f83a821df2292","client":"raw/1"}}]}\u001e
<- {"type":1,"target":"frame","arguments":[{"v":1,"type":"hello","id":"s_01M5QQA9VV7M69C19R54ZMKYBJ",
    "room":null,"ts":1792871311227,"payload":{"ref":"01JCCCCCCCCCCCCCCCCCCCCCCC","userId":"zed",
    "name":"zed","sessionId":"01M5QQA9VTJZZ0KH8E0PGA55K2","heartbeatMs":20000,"maxFrameBytes":16384,
    "resumed":false,"rooms":[]}}]}\u001e

The nine message types of the hub protocol specification are numbered in the type field.

Type Name Purpose

1

Invocation

Call a method. Without an invocationId it is fire-and-forget (send); with one, the caller expects a completion (invoke).

2

StreamItem

One item of a stream, tied to an invocationId.

3

Completion

End of an invocation or stream: carries a result, an error, or nothing.

4

StreamInvocation

Start a server-to-client stream (connection.stream(...)).

5

CancelInvocation

Cancel a running stream.

6

Ping

Keep-alive, {"type":6}; either side sends it only when it has sent nothing else for a while.

7

Close

Orderly close, optionally with an error and an allowReconnect flag.

8

Ack

Stateful reconnect: how many trackable messages the sender has received.

9

Sequence

Stateful reconnect: first message after a reconnect, naming where sending resumes.

The MessagePack protocol carries the same messages in a binary, length-prefixed form instead of 0x1E-terminated JSON. The handshake itself is always JSON. Because both protocols are installed on the server only when you add them, a client that asks for an unavailable protocol is refused in the handshake.

Compared with the plain WebSocket framing in The WebSocket Protocol, the hub protocol adds an RPC layer: method names, argument arrays, correlation ids, streaming and a ping that is separate from the WebSocket ping frame. It does not add delivery guarantees. A message sent while the connection is down is lost unless stateful reconnect is enabled (see Reconnection).

Hubs and the connection lifecycle

A hub is a class derived from Hub or Hub<T>. Its public methods are what clients can call. Four rules explain most hub bugs.

  1. A hub instance lives for one invocation. SignalR creates a new hub object for every call, so never keep state in a field. Per-connection state belongs in Context.Items, shared state in a singleton service or a store such as Redis.

  2. Calls from one connection run one at a time. MaximumParallelInvocationsPerClient defaults to 1, so a slow method delays the next message from that client. This preserves the order of a connection’s frames, which Chatterbox needs for sequence numbers, but it also means a blocking call stalls everything that client sends. Streaming invocations are not counted against this limit.

  3. Await every send. A call such as Clients.All.SendAsync(...) that is not awaited can fail after the hub method has already completed.

  4. Groups are not persisted. A connection that reconnects has a new ConnectionId and belongs to no group. The server does not remember its memberships; the application must re-add it, typically from the client after reconnect.

The lifecycle callbacks are OnConnectedAsync, called once the handshake is done, and OnDisconnectedAsync, called when the connection ends; its Exception argument is null for a deliberate stop() and set for a failure. Group membership is removed automatically on disconnect.

Addressing clients

Clients.All, Caller, Others, Client(id), Clients(ids), Group(name), GroupExcept, OthersInGroup, Groups(names), User(id) and Users(ids) select recipients. Clients.User uses the user identifier, which by default is the ClaimTypes.NameIdentifier claim; implement IUserIdProvider to use another claim such as the e-mail address. That identifier lets one person receive a message on every browser tab and device, which is how Chatterbox direct messages and cross-device presence work. A strongly typed hub (Hub<IChatClient>) replaces the string method names of SendAsync with an interface, at the cost of Native AOT compatibility (see Version notes and limits).

Sending from outside a hub

IHubContext<THub> can be injected into a controller, a background service or any singleton, and offers the same Clients and Groups properties without an active invocation. The example server uses it in two places: to send the hello-timeout error after five seconds and to broadcast the delayed offline presence after a user’s last connection closes. It cannot call Context, because there is no connection.

Client results

A server can also ask one client for a value: Clients.Client(id).InvokeAsync<T>("Method", cancellationToken) waits for the handler registered on that client to return. Use it sparingly. It is available for a single connection or the caller only, not for groups, and it needs a cancellation token because the client may never answer.

Hub filters

An IHubFilter wraps every hub invocation and connection event, which is the right place for logging, validation, rate limiting or tenant checks that must apply to all methods. The tested fragment in Production hardening fragments shows a timing filter.

Streaming

SignalR streams in both directions. A hub method that returns IAsyncEnumerable<T> or ChannelReader<T> becomes a server-to-client stream; a parameter of those types lets the client upload a stream. The client starts a download with connection.stream("Method", args).subscribe({ next, error, complete }), and can dispose the subscription to cancel, which trips the method’s CancellationToken. Items go through the same hub-protocol encoding, so a stream is many small messages, not one long body, and it respects the connection’s ordering.

Chatterbox uses a server stream for a full history export (see ExportHistory in the example): it reads Redis in pages of 50 and yields each stored message envelope, newest first, so the client can start rendering immediately and memory stays flat. The ordinary history envelope remains the right tool for paging a screen; the stream is for "download everything". Note that MaximumParallelInvocationsPerClient does not limit streams, so a client can open many at once; wrap the enumerable in a counter if you must cap them.

Reconnection

The JavaScript client does not reconnect by default. withAutomaticReconnect() retries after 0, 2, 10 and 30 seconds and then gives up, firing onreconnecting first and onreconnected (with a new connection id) on success, or onclose if every attempt fails. It does not retry a failed initial start(); that is the caller’s job. An array of delays or a retry-policy object replaces the schedule, and a policy that adds random jitter avoids a thundering herd after a server restart.

An automatic reconnect creates a new connection. To the server it is a stranger: no groups, no Context.Items, a new ConnectionId. For Chatterbox this means the client must send a new hello with a new one-time ticket and then rejoin every room with since set to the last sequence number it saw. The example client does exactly that in its onreconnected handler, and the Chatterbox sequence numbers make the catch-up exact.

Stateful reconnect (opt-in since .NET 8) moves part of this into the library. With AllowStatefulReconnects = true on MapHub and withStatefulReconnect() on the client, both sides buffer unacknowledged messages (100,000 bytes by default) and, after a short outage, the connection keeps its identity and replays the buffered messages using the Ack and Sequence messages of the hub protocol. The buffer is per connection and in memory, so it covers brief network drops, not a server restart or a move to another server; for those the application-level resume of Chatterbox is still needed. See Connection Lifecycle, Heartbeats and Reconnection for the general design.

Options and defaults

Option Default Effect

KeepAliveInterval

15 s

Server ping interval when the server has sent nothing else.

ClientTimeoutInterval

30 s

The server drops a client it has heard nothing from for this long; keep it at twice the keep-alive interval.

HandshakeTimeout

15 s

How long the server waits for the client’s handshake.

MaximumReceiveMessageSize

32 KB

Largest incoming hub message; a larger one aborts the connection.

MaximumParallelInvocationsPerClient

1

Hub calls of one client that run at once before queuing.

StreamBufferCapacity

10

Items buffered for a client-to-server stream.

EnableDetailedErrors

false

Send full exception messages to clients. Leave off in production.

StatefulReconnectBufferSize

100,000 bytes

Per-connection buffer for stateful reconnect.

On the client, withKeepAliveInterval (15 s) and withServerTimeout (30 s) mirror the server pair; the server timeout should stay at least twice the server keep-alive. Per-endpoint options such as Transports, AllowStatefulReconnects, CloseOnAuthenticationExpiration, ApplicationMaxBufferSize and TransportMaxBufferSize are set in the MapHub callback. The buffer sizes are 64 KB by default; a send larger than TransportMaxBufferSize throws on the server.

Chatterbox over SignalR: the mapping

SignalR has its own protocol, so the Chatterbox WebSocket details do not carry over one to one. The example makes one decision and keeps it everywhere: Chatterbox envelopes are the payload of two hub members. The client calls the hub method Frame with one envelope as its only argument; the server answers and broadcasts by calling the client method frame with one envelope. Acks and errors are Chatterbox envelopes delivered through frame, not SignalR completions, so the browser code that correlates ack.payload.ref with the request id is the same as for any other Chatterbox server. The other way to do it is a raw WebSocket endpoint inside the same ASP.NET Core app (app.UseWebSockets() and context.WebSockets.AcceptWebSocketAsync("chatterbox.v1")), which keeps the exact wire protocol but gives up groups, the backplane, reconnect and the SignalR clients; use that only when non-SignalR clients must connect.

Chatterbox rule Plain WebSocket server This SignalR server

Subprotocol chatterbox.v1

Requested in the handshake

Not used. SignalR negotiates json or messagepack; the envelope field v carries the version.

One envelope per frame

One text frame

One Frame invocation or one frame call.

maxFrameBytes 16384

Close 1009 or 4413

MaximumReceiveMessageSize = 16384. The hub-protocol wrapper counts too, so the envelope limit is slightly lower, and SignalR aborts the connection without an error envelope.

Application close codes 44xx

Sent in the close frame

SignalR cannot send custom close codes. The server sends an error envelope with the code, then calls Context.Abort().

Hello within 5 s (4408)

Timer closes the socket

Task.Delay in OnConnectedAsync sends unauthorized and aborts.

Heartbeat (ping every heartbeatMs)

WebSocket ping

SignalR keep-alive: 20 s interval and 40 s timeout, set to match heartbeatMs.

Per-connection rate limit

Token bucket

The same token bucket, stored in Context.Items.

Origin allow-list

Origin header check, HTTP 403

CORS for negotiate plus WebSocketOptions.AllowedOrigins (see Production hardening fragments).

Resume with sessionId

Server keeps session 120 s

Not implemented: resumed is always false and the client rejoins with since. Stateful reconnect could shorten brief outages.

Ordering by seq

Single socket loop

One invocation at a time per connection, plus a Redis INCR per room, so ordering holds across servers.

The server implements hello, join, leave, message, typing, presence, history, ack and error, with per-room sequence numbers and idempotent ids. edit, react and receipt are answered with bad_request; adding them follows the same pattern as message. The full protocol is on Message Design and Serialization.

The example server

The server keeps no chat state in process. Sequence numbers, history, membership, idempotency records, one-time tickets and connection counts all live in Redis, so two server instances behind the same Redis give consistent answers, and the SignalR Redis backplane carries the broadcasts between them. Docker is the only requirement: the SDK image builds the project and no .NET SDK is needed on the host.

Project file with the pinned package:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="10.0.13" />
  </ItemGroup>
</Project>

Program.cs registers Redis, the store and SignalR with the options that realise heartbeatMs and maxFrameBytes, adds the backplane with its own channel prefix, and exposes the ticket endpoint and the hub:

using Chatterbox;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);
var redisConnection = builder.Configuration["REDIS"] ?? "localhost:6379";

builder.Services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(redisConnection));
builder.Services.AddSingleton<ChatStore>();
builder.Services.AddSignalR(o =>
{
    o.KeepAliveInterval = TimeSpan.FromSeconds(20);      // heartbeatMs
    o.ClientTimeoutInterval = TimeSpan.FromSeconds(40);  // 2 x heartbeatMs
    o.MaximumReceiveMessageSize = 16384;                 // maxFrameBytes
    o.MaximumParallelInvocationsPerClient = 1;           // frames of one connection run in order
})
.AddStackExchangeRedis(redisConnection, o =>
    o.Configuration.ChannelPrefix = RedisChannel.Literal("chatterbox"));

var app = builder.Build();

// Stand-in for a real login: returns a one-time ticket valid for 30 seconds.
app.MapPost("/api/ticket", async (string user, ChatStore store) =>
    Results.Ok(new { ticket = await store.IssueTicketAsync(user) }));

app.MapGet("/healthz", () => Results.Ok(new { instance = Environment.MachineName }));
app.MapHub<ChatHub>("/hubs/chatterbox");
app.Run();

Support.cs holds the error type, the token bucket, ULID generation for server ids, and the Redis-backed store:

using System.Security.Cryptography;
using System.Text.Json.Nodes;
using StackExchange.Redis;

namespace Chatterbox;

public sealed class ChatException(string code, string message, bool retryable = false) : Exception(message)
{
    public string Code { get; } = code;
    public bool Retryable { get; } = retryable;
}

/// <summary>20 tokens per second, capacity 40, per connection.</summary>
public sealed class TokenBucket
{
    private double _tokens = 40;
    private long _last = Environment.TickCount64;
    public int Violations;

    public bool TryTake()
    {
        var now = Environment.TickCount64;
        _tokens = Math.Min(40, _tokens + (now - _last) / 1000.0 * 20);
        _last = now;
        if (_tokens < 1) return false;
        _tokens -= 1;
        return true;
    }
}

public static class Ids
{
    private const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";

    /// <summary>26-character ULID: 48-bit time plus 80 random bits, Crockford base32.</summary>
    public static string Ulid()
    {
        Span<byte> b = stackalloc byte[16];
        var ms = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        for (var i = 5; i >= 0; i--) { b[i] = (byte)(ms & 0xFF); ms >>= 8; }
        RandomNumberGenerator.Fill(b[6..]);
        var chars = new char[26];
        var bits = new System.Numerics.BigInteger(b, isUnsigned: true, isBigEndian: true);
        for (var i = 25; i >= 0; i--) { chars[i] = Alphabet[(int)(bits & 31)]; bits >>= 5; }
        return new string(chars);
    }
    public static string Server() => "s_" + Ulid();
}

public sealed class ChatStore(IConnectionMultiplexer redis)
{
    private IDatabase Db => redis.GetDatabase();

    public async Task<string> IssueTicketAsync(string user)
    {
        var ticket = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
        await Db.StringSetAsync($"ticket:{ticket}", user, TimeSpan.FromSeconds(30));
        return ticket;
    }

    // GETDEL makes the ticket one-time even across several server instances.
    public async Task<string?> RedeemTicketAsync(string ticket)
        => (string?)await Db.StringGetDeleteAsync($"ticket:{ticket}");

    public Task<long> NextSeqAsync(string room) => Db.StringIncrementAsync($"room:{room}:seq");

    public async Task<long> LastSeqAsync(string room)
        => (long)await Db.StringGetAsync($"room:{room}:seq");

    public Task AddMessageAsync(string room, long seq, JsonNode envelope)
        => Db.SortedSetAddAsync($"room:{room}:messages", envelope.ToJsonString(), seq);

    public async Task<List<JsonNode>> MessagesAsync(string room, long? before, int limit)
    {
        var max = before is null ? double.PositiveInfinity : before.Value;
        var exclude = before is null ? Exclude.None : Exclude.Stop;
        var values = await Db.SortedSetRangeByScoreAsync($"room:{room}:messages", double.NegativeInfinity, max,
            exclude, Order.Descending, 0, limit);
        return values.Select(v => JsonNode.Parse((string)v!)!).Reverse().ToList();
    }

    public async Task<List<JsonNode>> SinceAsync(string room, long since)
    {
        var values = await Db.SortedSetRangeByScoreAsync($"room:{room}:messages", since, double.PositiveInfinity,
            Exclude.Start, Order.Ascending);
        return values.Select(v => JsonNode.Parse((string)v!)!).ToList();
    }

    public async Task<string?> GetAckAsync(string user, string id)
        => await Db.StringGetAsync($"ack:{user}:{id}");

    public Task SetAckAsync(string user, string id, string ack)
        => Db.StringSetAsync($"ack:{user}:{id}", ack, TimeSpan.FromHours(24));

    public Task<bool> AddMemberAsync(string room, string user)
        => Db.SetAddAsync($"room:{room}:members", user);
    public Task<bool> RemoveMemberAsync(string room, string user)
        => Db.SetRemoveAsync($"room:{room}:members", user);
    public Task<bool> IsMemberAsync(string room, string user)
        => Db.SetContainsAsync($"room:{room}:members", user);
    public async Task<string[]> MembersAsync(string room)
        => (await Db.SetMembersAsync($"room:{room}:members")).Select(v => (string)v!).ToArray();
    public async Task<string[]> RoomsOfAsync(string user)
        => (await Db.SetMembersAsync($"user:{user}:rooms")).Select(v => (string)v!).ToArray();
    public Task TrackRoomAsync(string user, string room, bool add)
        => add ? Db.SetAddAsync($"user:{user}:rooms", room) : Db.SetRemoveAsync($"user:{user}:rooms", room);

    public Task<long> ConnectionsAsync(string user, int delta)
        => Db.StringIncrementAsync($"user:{user}:conns", delta);
    public async Task<long> ConnectionCountAsync(string user)
        => (long)await Db.StringGetAsync($"user:{user}:conns");

    public Task SetStatusAsync(string user, string status) => Db.StringSetAsync($"user:{user}:status", status);
    public async Task<string> StatusAsync(string user)
        => (string?)await Db.StringGetAsync($"user:{user}:status") ?? "online";
}

ChatHub.cs is the protocol. Frame is the only hub method clients call. It applies rate limiting, the version and handshake checks and the idempotency lookup, dispatches by envelope type, stores the ack, and turns ChatException into an error envelope. ExportHistory is the streaming method.

using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.SignalR;

namespace Chatterbox;

public sealed partial class ChatHub(ChatStore store, IHubContext<ChatHub> hubs, IConfiguration config) : Hub
{
    [GeneratedRegex("^[a-z0-9-]{1,64}$")] private static partial Regex RoomName();

    private static readonly string[] AckedTypes = ["join", "leave", "message", "presence", "history"];
    private string? UserId => Context.Items["uid"] as string;

    // ---------- connection lifecycle ----------

    public override async Task OnConnectedAsync()
    {
        Context.Items["bucket"] = new TokenBucket();
        var connectionId = Context.ConnectionId;
        var context = Context;
        // hello must arrive within 5 s, otherwise the server aborts the connection
        _ = Task.Run(async () =>
        {
            await Task.Delay(5000);
            if (context.Items["uid"] is null)
            {
                await hubs.Clients.Client(connectionId).SendAsync("frame",
                    Envelope("error", null, ErrorPayload(null, "unauthorized", "hello timeout", false)));
                context.Abort();
            }
        });
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        if (UserId is { } user)
        {
            await store.ConnectionsAsync(user, -1);
            _ = Task.Run(() => OfflineAfterGraceAsync(user));
        }
        await base.OnDisconnectedAsync(exception);
    }

    private async Task OfflineAfterGraceAsync(string user)
    {
        await Task.Delay(config.GetValue("PRESENCE_GRACE_MS", 15000));
        if (await store.ConnectionCountAsync(user) > 0) return;
        foreach (var room in await store.RoomsOfAsync(user))
            await hubs.Clients.Group(Group(room)).SendAsync("frame",
                Envelope("presence", room, new JsonObject { ["userId"] = user, ["name"] = user, ["status"] = "offline" }));
    }

    // ---------- the single entry point: one Chatterbox envelope per invocation ----------

    public async Task Frame(JsonElement raw)
    {
        var frame = JsonNode.Parse(raw.GetRawText())!.AsObject();
        var id = frame["id"]?.GetValue<string>();
        try
        {
            if (!((TokenBucket)Context.Items["bucket"]!).TryTake())
            {
                var bucket = (TokenBucket)Context.Items["bucket"]!;
                if (++bucket.Violations >= 10) { await Fail(id, "rate_limited", "slow down", true); Context.Abort(); return; }
                throw new ChatException("rate_limited", "slow down", true);
            }
            if (frame["v"]?.GetValue<int>() != 1)
            {
                await Fail(id, "unsupported_version", "only v1", false);
                Context.Abort();
                return;
            }
            var type = frame["type"]?.GetValue<string>() ?? throw new ChatException("bad_request", "type missing");
            if (id is null || id.Length is < 26 or > 36) throw new ChatException("bad_request", "id must be 26-36 chars");
            var payload = frame["payload"] as JsonObject ?? throw new ChatException("bad_request", "payload must be an object");
            var room = frame["room"]?.GetValue<string>();

            if (type == "hello") { await Hello(id, payload); return; }
            if (UserId is not { } user)
            {
                await Fail(id, "unauthorized", "send hello first", false);
                Context.Abort();
                return;
            }
            if (room is not null && !RoomName().IsMatch(room)) throw new ChatException("bad_request", "bad room name");

            if (AckedTypes.Contains(type) && await store.GetAckAsync(user, id) is { } cached)
            {
                var again = JsonNode.Parse(cached)!.AsObject();
                again["payload"]!["dup"] = true;       // same ack, no new seq, no re-broadcast
                again["ts"] = Now();
                await Clients.Caller.SendAsync("frame", again);
                return;
            }

            var (seq, result) = type switch
            {
                "join" => await Join(user, Need(room), payload),
                "leave" => await Leave(user, Need(room)),
                "message" => await Message(user, id, Need(room), payload),
                "presence" => await Presence(user, payload),
                "history" => await History(Need(room), payload),
                "typing" => await Typing(user, Need(room), payload),
                _ => throw new ChatException("bad_request", $"type '{type}' is not implemented here"),
            };
            if (type == "typing") return;               // fire-and-forget: no ack

            var ack = Envelope("ack", room, new JsonObject
            {
                ["ref"] = id, ["ok"] = true, ["seq"] = seq, ["dup"] = false, ["result"] = result,
            });
            await store.SetAckAsync(user, id, ack.ToJsonString());   // only acks are remembered
            await Clients.Caller.SendAsync("frame", ack);
        }
        catch (ChatException e)
        {
            await Fail(id, e.Code, e.Message, e.Retryable);
        }
    }

    // Server-to-client stream: the whole room history, one message envelope per stream item.
    public async IAsyncEnumerable<JsonNode> ExportHistory(string room, [EnumeratorCancellation] CancellationToken ct)
    {
        var user = UserId ?? throw new HubException("send hello first");
        if (!RoomName().IsMatch(room) || !await store.IsMemberAsync(room, user)) throw new HubException("forbidden");
        long? before = null;
        while (true)
        {
            var page = await store.MessagesAsync(room, before, 50);
            if (page.Count == 0) yield break;
            foreach (var m in Enumerable.Reverse(page)) { ct.ThrowIfCancellationRequested(); yield return m; }
            before = page[0]["payload"]!["seq"]!.GetValue<long>();
        }
    }

    // ---------- handlers ----------

    private async Task Hello(string id, JsonObject payload)
    {
        if (UserId is not null) throw new ChatException("conflict", "already authenticated");
        var ticket = payload["token"]?.GetValue<string>();
        var user = ticket is null ? null : await store.RedeemTicketAsync(ticket);
        if (user is null)
        {
            await Fail(id, "unauthorized", "invalid or used ticket", false);
            Context.Abort();
            return;
        }
        Context.Items["uid"] = user;
        await store.ConnectionsAsync(user, 1);
        await Clients.Caller.SendAsync("frame", Envelope("hello", null, new JsonObject
        {
            ["ref"] = id, ["userId"] = user, ["name"] = user, ["sessionId"] = Ids.Ulid(),
            ["heartbeatMs"] = 20000, ["maxFrameBytes"] = 16384, ["resumed"] = false,
            ["rooms"] = new JsonArray((await store.RoomsOfAsync(user)).Select(r => (JsonNode)r).ToArray()),
        }));
    }

    private async Task<(long?, JsonObject)> Join(string user, string room, JsonObject payload)
    {
        if (room.StartsWith("dm-") && !room.Split('-').Contains(user))
            throw new ChatException("forbidden", "not your direct-message room");
        var added = await store.AddMemberAsync(room, user);
        await store.TrackRoomAsync(user, room, true);
        await Groups.AddToGroupAsync(Context.ConnectionId, Group(room));
        if (added)
            await Clients.GroupExcept(Group(room), Context.ConnectionId).SendAsync("frame", Envelope("presence", room,
                new JsonObject { ["userId"] = user, ["name"] = user, ["status"] = await store.StatusAsync(user) }));
        var members = new JsonArray();
        foreach (var m in await store.MembersAsync(room))
            members.Add(new JsonObject { ["userId"] = m, ["name"] = m, ["status"] = await store.StatusAsync(m) });
        if (payload["since"] is JsonValue s && s.TryGetValue<long>(out var since))
            foreach (var missed in await store.SinceAsync(room, since))
                await Clients.Caller.SendAsync("frame", missed);
        return (null, new JsonObject { ["members"] = members, ["lastSeq"] = await store.LastSeqAsync(room) });
    }

    private async Task<(long?, JsonObject)> Leave(string user, string room)
    {
        if (!await store.RemoveMemberAsync(room, user)) throw new ChatException("not_found", "not a member");
        await store.TrackRoomAsync(user, room, false);
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, Group(room));
        return (null, new JsonObject());
    }

    private async Task<(long?, JsonObject)> Message(string user, string id, string room, JsonObject payload)
    {
        if (!await store.IsMemberAsync(room, user)) throw new ChatException("forbidden", "join the room first");
        var text = payload["text"]?.GetValue<string>();
        if (string.IsNullOrEmpty(text) || text.Length > 4000) throw new ChatException("bad_request", "text must be 1..4000");
        var seq = await store.NextSeqAsync(room);
        var broadcast = new JsonObject
        {
            ["v"] = 1, ["type"] = "message", ["id"] = id, ["room"] = room, ["ts"] = Now(),
            ["payload"] = new JsonObject
            {
                ["from"] = new JsonObject { ["userId"] = user, ["name"] = user },
                ["text"] = text, ["replyTo"] = payload["replyTo"]?.DeepClone(),
                ["attachments"] = payload["attachments"]?.DeepClone() ?? new JsonArray(),
                ["seq"] = seq, ["edited"] = false,
            },
        };
        await store.AddMessageAsync(room, seq, broadcast);
        await Clients.Group(Group(room)).SendAsync("frame", broadcast);   // includes the sender
        return (seq, new JsonObject());
    }

    private async Task<(long?, JsonObject)> Presence(string user, JsonObject payload)
    {
        var status = payload["status"]?.GetValue<string>();
        if (status is not ("online" or "away")) throw new ChatException("bad_request", "status");
        var changed = await store.StatusAsync(user) != status;
        await store.SetStatusAsync(user, status);
        if (changed)
            foreach (var room in await store.RoomsOfAsync(user))
                await Clients.Group(Group(room)).SendAsync("frame", Envelope("presence", room,
                    new JsonObject { ["userId"] = user, ["name"] = user, ["status"] = status }));
        return (null, new JsonObject());
    }

    private async Task<(long?, JsonObject)> History(string room, JsonObject payload)
    {
        var user = UserId!;
        if (!await store.IsMemberAsync(room, user)) throw new ChatException("forbidden", "join the room first");
        var limit = Math.Clamp(payload["limit"]?.GetValue<int>() ?? 50, 1, 100);
        long? before = payload["before"] is JsonValue b && b.TryGetValue<long>(out var bv) ? bv : null;
        var page = await store.MessagesAsync(room, before, limit + 1);   // one extra row tells hasMore
        var hasMore = page.Count > limit;
        if (hasMore) page.RemoveAt(0);
        long? next = hasMore ? page[0]["payload"]!["seq"]!.GetValue<long>() : null;
        return (null, new JsonObject { ["messages"] = new JsonArray(page.ToArray()), ["hasMore"] = hasMore, ["next"] = next });
    }

    private async Task<(long?, JsonObject)> Typing(string user, string room, JsonObject payload)
    {
        if (!await store.IsMemberAsync(room, user)) throw new ChatException("forbidden", "join the room first");
        await Clients.GroupExcept(Group(room), Context.ConnectionId).SendAsync("frame", Envelope("typing", room,
            new JsonObject { ["userId"] = user, ["state"] = payload["state"]?.GetValue<string>() ?? "start" }));
        return (null, new JsonObject());
    }

    // ---------- helpers ----------

    private static string Group(string room) => "room:" + room;
    private static long Now() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    private static string Need(string? room) => room ?? throw new ChatException("bad_request", "room required");

    private static JsonObject Envelope(string type, string? room, JsonObject payload) => new()
    {
        ["v"] = 1, ["type"] = type, ["id"] = Ids.Server(), ["room"] = room, ["ts"] = Now(), ["payload"] = payload,
    };

    private static JsonObject ErrorPayload(string? @ref, string code, string message, bool retryable) => new()
    {
        ["ref"] = @ref, ["code"] = code, ["message"] = message, ["retryable"] = retryable,
    };

    private Task Fail(string? @ref, string code, string message, bool retryable)
        => Clients.Caller.SendAsync("frame", Envelope("error", null, ErrorPayload(@ref, code, message, retryable)));
}

The Dockerfile builds with the SDK image and runs on the smaller ASP.NET runtime image; the Compose file starts Redis and two instances of the same image:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Chatterbox.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "Chatterbox.dll"]
services:
  redis:
    image: redis:8.8.3-alpine
    ports:
      - "6390:6379"
  chat-a:
    build: .
    environment:
      REDIS: redis:6379
      PRESENCE_GRACE_MS: "2000"
    ports:
      - "8190:8080"
    depends_on: [redis]
  chat-b:
    build: .
    environment:
      REDIS: redis:6379
      PRESENCE_GRACE_MS: "2000"
    ports:
      - "8191:8080"
    depends_on: [redis]

Running it

cd chatterbox-signalr/server
docker compose -p chatterbox-signalr up -d --build      # redis on 6390, instances on 8190 and 8191
cd ../client
npm install
node check.mjs                                          # two clients, two servers, one Redis
docker compose -p chatterbox-signalr down -v

Two instances, chat-a on port 8190 and chat-b on port 8191, share one Redis. The test connects Alice to the first and Bob to the second.

The example client

The client wraps one HubConnection. It fetches a one-time ticket, sends hello, correlates each request envelope with its ack, error or hello reply through the ref field and a 10 second timeout, tracks the last seq per room, and after an automatic reconnect repeats the hello and rejoins every room with since. Handlers are registered before start(), as the SignalR documentation advises, so that no message can arrive without a handler.

import { HubConnectionBuilder, HttpTransportType, LogLevel } from "@microsoft/signalr";

const ulid = () => crypto.randomUUID().replaceAll("-", "").slice(0, 26).toUpperCase();

export class ChatterboxClient extends EventTarget {
  #conn; #pending = new Map(); #rooms = new Map(); // room -> last seq seen
  #base; #user;

  constructor(baseUrl, user) {
    super();
    this.#base = baseUrl;
    this.#user = user;
    this.#conn = new HubConnectionBuilder()
      .withUrl(`${baseUrl}/hubs/chatterbox`, { transport: HttpTransportType.WebSockets })
      .withAutomaticReconnect([0, 1000, 3000, 10000])
      .withKeepAliveInterval(20_000)   // matches the server's KeepAliveInterval (heartbeatMs)
      .withServerTimeout(40_000)       // 2 x the keep-alive interval
      .configureLogging(LogLevel.Warning)
      .build();

    this.#conn.on("frame", (env) => this.#onFrame(env));
    this.#conn.onreconnected(async () => {
      await this.#hello();                                  // a new connection id needs a new hello
      for (const [room, seq] of this.#rooms) await this.join(room, seq || null);  // groups are not persisted
      this.dispatchEvent(new Event("reconnected"));
    });
  }

  async start() {
    await this.#conn.start();
    return this.#hello();
  }

  async #hello() {
    const res = await fetch(`${this.#base}/api/ticket?user=${this.#user}`, { method: "POST" });
    const { ticket } = await res.json();
    return this.#send("hello", null, { token: ticket, client: "web/1.4" });
  }

  #send(type, room, payload) {
    const env = { v: 1, type, id: ulid(), room, ts: 0, payload };
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this.#pending.delete(env.id);
        reject(new Error(`ack timeout for ${type}`));
      }, 10_000);
      this.#pending.set(env.id, { resolve, reject, timer });
      this.#conn.send("Frame", env).catch(reject);          // fire-and-forget; the ack arrives as a frame
    });
  }

  #onFrame(env) {
    const ref = env.payload?.ref;
    const waiter = ref && this.#pending.get(ref);
    if (waiter && ["ack", "error", "hello"].includes(env.type)) {
      clearTimeout(waiter.timer);
      this.#pending.delete(ref);
      if (env.type === "error") waiter.reject(Object.assign(new Error(env.payload.message), env.payload));
      else waiter.resolve(env.payload);
      return;
    }
    const seq = env.payload?.seq;
    if (seq && env.room) this.#rooms.set(env.room, Math.max(this.#rooms.get(env.room) ?? 0, seq));
    this.dispatchEvent(new CustomEvent(env.type, { detail: env }));
  }

  async join(room, since = null) {
    if (!this.#rooms.has(room)) this.#rooms.set(room, 0);
    return this.#send("join", room, { since });
  }
  message(room, text) { return this.#send("message", room, { text, replyTo: null, attachments: [] }); }
  history(room, before = null, limit = 50) { return this.#send("history", room, { before, limit }); }
  presence(status) { return this.#send("presence", null, { status }); }
  typing(room, state) { this.#conn.send("Frame", { v: 1, type: "typing", id: ulid(), room, ts: 0, payload: { state } }); }
  // resend an envelope with its ORIGINAL id (idempotent retry)
  raw(env) { return new Promise((resolve) => { this.#pending.set(env.id, { resolve, reject: resolve, timer: setTimeout(() => {}, 0) }); this.#conn.send("Frame", env); }); }
  exportHistory(room, onItem) {
    return new Promise((resolve, reject) =>
      this.#conn.stream("ExportHistory", room).subscribe({ next: onItem, error: reject, complete: resolve }));
  }
  stop() { return this.#conn.stop(); }
}

The send("Frame", envelope) call is fire-and-forget: its promise resolves when the message has been written, not when the server has processed it. That is intentional, since the Chatterbox ack is the confirmation. Using invoke would add a second, redundant completion message for every frame.

Install with npm install @microsoft/signalr@10.0.11. In the browser, load the same package through a bundler or import its signalr.js build; the code above uses only the public API. Node 22 and later also work, which is how the tests run.

A test that proves the behaviour

Save the script below as check.mjs next to the client. It drives the two servers and asserts the Chatterbox rules: join with the member list, broadcast through the Redis backplane to a client on the other instance, sequence numbers assigned by Redis, an idempotent duplicate (dup: true, same seq, one broadcast) and the forbidden and bad_request errors. Pace any further test traffic: more than about 40 frames at once is rejected with rate_limited.

import assert from "node:assert/strict";
import { ChatterboxClient } from "./chatterbox-client.mjs";

const room = "room-" + Math.random().toString(36).slice(2, 8);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const alice = new ChatterboxClient("http://localhost:8190", "alice");   // instance A
const bob = new ChatterboxClient("http://localhost:8191", "bob");       // instance B
await alice.start();
await bob.start();

const bobMsgs = [];
bob.addEventListener("message", (e) => bobMsgs.push(e.detail));
await alice.join(room);
const joined = await bob.join(room);
assert.deepEqual(joined.result.members.map((m) => m.userId).sort(), ["alice", "bob"]);

const a1 = await alice.message(room, "hello from A");
const a2 = await alice.message(room, "second");
assert.deepEqual([a1.seq, a2.seq], [1, 2]);          // sequence numbers come from Redis INCR
await sleep(300);
assert.deepEqual(bobMsgs.map((e) => e.payload.seq), [1, 2]);   // delivered via the backplane

const env = { v: 1, type: "message", id: "01JZ" + Math.random().toString(36).slice(2).toUpperCase().padEnd(22, "A").slice(0, 22), room, ts: 0,
              payload: { text: "once", replyTo: null, attachments: [] } };
const first = await alice.raw(env);
const second = await alice.raw(env);                 // same id again
assert.equal(first.dup, false);
assert.equal(second.dup, true);
assert.equal(first.seq, second.seq);

await assert.rejects(alice.message("nowhere", "x"), (e) => e.code === "forbidden");
await assert.rejects(alice.message(room, ""), (e) => e.code === "bad_request");
await alice.stop();
await bob.stop();
console.log("OK");

Production hardening fragments

The following Program fragment is not part of the running example because it needs a real identity provider and the Azure service, but it compiles against the pinned packages (Microsoft.AspNetCore.Authentication.JwtBearer 10.0.13, Microsoft.AspNetCore.SignalR.Protocols.MessagePack 10.0.13 and Microsoft.Azure.SignalR 1.33.1). It shows the settings discussed in the next sections: bearer tokens read from the query string for hub paths, a CORS policy and a WebSocket origin list, a hub filter, the MessagePack protocol, WebSockets-only transport, stateful reconnect and closing connections whose token expired. Secrets are placeholders. Normally you register either the Azure service or the Redis backplane, not both.

using System.Diagnostics;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = "https://chat.example.test",
            ValidAudience = "chatterbox",
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("change-me-change-me-change-me-32b")),
        };
        o.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var token = context.Request.Query["access_token"];
                if (!string.IsNullOrEmpty(token) && context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
                    context.Token = token;
                return Task.CompletedTask;
            },
        };
    });
builder.Services.AddAuthorization();
builder.Services.AddCors(o => o.AddPolicy("chat", p => p
    .WithOrigins("https://chat.example.test").WithMethods("GET", "POST", "DELETE").AllowAnyHeader().AllowCredentials()));

builder.Services.AddSingleton<AuditFilter>();
builder.Services
    .AddSignalR(o => o.AddFilter<AuditFilter>())
    .AddMessagePackProtocol()
    .AddAzureSignalR(o => o.ConnectionString = "Endpoint=https://example.service.signalr.net;AccessKey=change-me;Version=1.0;");

var app = builder.Build();
app.UseWebSockets(new Microsoft.AspNetCore.Builder.WebSocketOptions { AllowedOrigins = { "https://chat.example.test" } });
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<ChatHub>("/hubs/chatterbox", o =>
{
    o.Transports = HttpTransportType.WebSockets;
    o.AllowStatefulReconnects = true;
    o.CloseOnAuthenticationExpiration = true;
}).RequireCors("chat");
app.Run();

public sealed class AuditFilter(ILogger<AuditFilter> log) : IHubFilter
{
    public async ValueTask<object?> InvokeMethodAsync(HubInvocationContext ctx, Func<HubInvocationContext, ValueTask<object?>> next)
    {
        var watch = Stopwatch.StartNew();
        try { return await next(ctx); }
        finally { log.LogInformation("{Method} took {Ms} ms", ctx.HubMethodName, watch.ElapsedMilliseconds); }
    }
}

[Microsoft.AspNetCore.Authorization.Authorize]
public sealed class ChatHub : Hub { }

Authentication and authorization

Browsers cannot set headers on a WebSocket, so SignalR’s JavaScript client sends the bearer token as the access_token query-string parameter for the WebSocket and Server-Sent Events transports; the accessTokenFactory option supplies it. The server must read it back: with JWT bearer authentication, hook OnMessageReceived as in the fragment above, and only for hub paths. Cookie authentication needs no such hook, because the cookie travels with the negotiate request and the WebSocket upgrade of the same origin.

Two cautions follow from the query string. Web servers and proxies log URLs, so the token may reach access logs; raise the Microsoft.AspNetCore.Hosting log level to Warning or filter the parameter, and always use HTTPS. And a token in a URL must be short lived. Chatterbox sidesteps both by using the token only on a separate POST /api/ticket request and sending a one-time ticket (30 seconds, redeemed with Redis GETDEL) inside the hello envelope, so the long-lived credential never appears in a hub URL. The example’s /api/ticket?user=alice endpoint is a stand-in for a real login and must not be exposed as is.

Authorization works as in MVC: [Authorize] on a hub or a method, with policies. It is evaluated when the connection is established and per method call, not on every message. A token that expires while a connection is open does not close it by default. Set CloseOnAuthenticationExpiration = true so the server closes the connection when the token expires and the client reconnects with a new token. .NET 11 adds authentication refresh (EnableAuthenticationRefresh on the server, withAuthenticationRefresh() and refreshAuthentication() on the JavaScript client) so a client can swap credentials on the open connection; it adds a refresh route next to negotiate, which proxies and firewalls must allow, and it does not change the user identifier. On .NET 10 the supported pattern is close and reconnect. For the policy model see Authorization, and for the general WebSocket design see Authentication and Authorization.

Security checklist

  • Origins. CORS protects the negotiate request only. The WebSocket handshake ignores CORS, so also restrict it with WebSocketOptions.AllowedOrigins and remember that a non-browser client can send any Origin; the check is protection against cross-site hijacking by a browser, not authentication. CORS for SignalR needs GET, POST and DELETE and credentials allowed, and a named origin, never any origin with credentials.

  • Never expose ConnectionId. It identifies a connection to the server; treat it as internal. Hub methods that take a connection id from the client let one user address another’s connection.

  • Size limits. Keep MaximumReceiveMessageSize small, set it per hub with AddHubOptions<THub> if one hub needs more, and send large payloads through plain HTTP. Setting a buffer to zero removes the limit and invites memory exhaustion.

  • Errors. An exception in a hub method reaches the client only as a generic message. Throw HubException for text you want the client to see, and leave EnableDetailedErrors off outside development. The example maps expected failures to Chatterbox error envelopes instead.

  • Validate every argument. A hub method is a public RPC surface. Check the room name pattern, membership and message length as the example does, and never trust a user name sent by the client: take the identity from Context.

  • Rate limit. Per-connection limits in the hub (as the token bucket here) complement the HTTP rate limiter, which sees only the negotiate request.

  • Do not log tokens or ticket values.

More on the shared WebSocket threats (cross-site hijacking, message flooding, injection) is in Security; the ASP.NET Core hardening basics are in Security Hardening.

Scale-out

One server holds its connections in memory. With more than one, a message sent from a hub on server A must reach clients connected to server B, and that is what a backplane or the Azure service provides.

flowchart LR subgraph SELF["Redis backplane (your servers)"] direction TB CA["Browser A"] --> LB["Load balancer, sticky sessions"] CB["Browser B"] --> LB LB --> S1["App server 1: hubs and connections"] LB --> S2["App server 2: hubs and connections"] S1 <--> R[("Redis pub/sub")] S2 <--> R end subgraph AZ["Azure SignalR Service"] direction TB CC["Browser C"] --> SVC["Service holds all client connections"] CD["Browser D"] --> SVC SVC <--> H1["App server 1: hub logic only"] SVC <--> H2["App server 2: hub logic only"] end

Redis backplane

AddStackExchangeRedis makes each server publish group and user sends to Redis channels (here prefixed chatterbox) and subscribe to the same channels, so every server forwards the message to its own local connections. Facts to plan around:

  • It needs sticky sessions, because the negotiate call and the transport connection must meet the same process; the only exception is a WebSockets-only client with skipNegotiation.

  • It carries sends, not state. It does not store messages, group membership lists or history, and it does not replay what a disconnected server missed. While Redis is unavailable, messages between servers are lost. That is why the example keeps durable chat state in Redis keys of its own and treats the backplane as fan-out only.

  • Clients scale with servers, not with message rate: each connection still occupies a socket and memory on an app server.

  • Group membership is a routing aid, not a queryable list. Keep the authoritative member list in your own store, as the example does with a Redis set per room.

  • Other backplanes exist (SQL Server, Orleans, Rebus, NCache) from third parties; Redis is the Microsoft-documented choice for self-hosted farms.

Azure SignalR Service

The Azure service is a managed proxy and connection holder. Clients are redirected to it at negotiate time and keep their sockets there, while each app server keeps a small constant number of connections to the service. That removes the need for sticky sessions and decouples connection count from server count; each unit supports up to 1,000 client connections, and the service carries a 99.9% SLA. Enable it with AddAzureSignalR() and a connection string held in configuration, never in source. Its service modes decide who runs hub code:

  • Default: a normal hub server is required and registered with the service. Client connections stay on the same hub server for their lifetime. Without a connected hub server, clients cannot connect.

  • Serverless: no hub server at all. Messages are sent through the REST API, the management SDK or Azure Functions bindings, and client messages reach your code through upstream endpoints. Calling hub methods from the client is not supported in this mode.

  • Classic: a legacy mixed mode kept for compatibility; avoid it for new applications.

Microsoft recommends the Azure service for apps hosted on Azure, and Redis for your own infrastructure when latency to Azure would be a problem. The mapping of Chatterbox to Serverless would need a different design (upstream HTTP, not a hub), so the example targets Default mode or Redis.

For the general scaling theory (sticky sessions, backplanes, fan-out cost) read Scaling WebSockets, and for load balancer settings read Proxies, Load Balancers and Deployment. Behind Nginx, set Upgrade and Connection headers, proxy_http_version 1.1, and proxy_buffering off for Server-Sent Events; if KeepAliveInterval is above 60 seconds, raise proxy_read_timeout.

Common errors and pitfalls

  • Handlers registered after start(). Messages sent in between are dropped silently. Register on handlers first.

  • Method name mismatch. The server calls the client method named frame and the JavaScript client matches handler names case-insensitively, but a misspelled name raises no error, only silence. A strongly typed hub removes this class of bug.

  • Expecting groups to survive reconnect. They do not. Rejoin after onreconnected.

  • Per-hub state in fields. The hub is recreated per call; the field is always empty.

  • Un-awaited sends. Add await; otherwise exceptions vanish or the send races the hub’s disposal.

  • Blocking in a hub method. One slow call blocks that client’s later calls because invocations run one at a time.

  • Using Clients.Others or Caller from IHubContext. They exist only inside a hub invocation.

  • Sticky sessions missing. Symptom: intermittent "connection not found" or 404 on the transport request, with long polling or SSE fallback. Fix with affinity, WebSockets-only plus skipNegotiation, or the Azure service.

  • Idle timeouts at a proxy. Keep-alive pings every 15 seconds by default; a proxy that closes idle connections sooner causes endless reconnects.

  • Browser tab sleeping. Background tabs can be frozen and close the connection; a shared Web Lock keeps the tab awake if that matters to you.

  • Windows client IIS limits. IIS on Windows client editions allows 10 concurrent connections; use Kestrel in development.

  • Message too large. Over MaximumReceiveMessageSize the connection is aborted without an application error.

  • Retrying ids. A retry after a lost ack must reuse the same envelope id; the example then answers with the cached ack and dup: true, and broadcasts nothing a second time.

  • Rate limiting your own tests. A loop sending more than about 40 frames at once is correctly rejected with rate_limited; pace test traffic.

Testing and diagnostics

Server logging categories Microsoft.AspNetCore.SignalR and Microsoft.AspNetCore.Http.Connections explain negotiate, transport and protocol failures; the client’s configureLogging(LogLevel.Trace) shows every message. SignalR also emits ActivitySource traces and meters that OpenTelemetry can collect, described on Observability. For automated tests, host the app with WebApplicationFactory and connect a .NET HubConnection to the test server, or run the app in a container and drive it with a script like the one above; Testing ASP.NET Core covers the factory and Testing Real-Time Apps covers the wider strategy.

Clients

The JavaScript and TypeScript client (@microsoft/signalr) runs in browsers and Node. The .NET client (Microsoft.AspNetCore.SignalR.Client) targets any .NET runtime, including MAUI and Blazor WebAssembly. Java and Swift clients are documented by Microsoft as well. The Java client supports only WebSockets and no transport fallback. Mobile specifics (backgrounding, reconnect on network change, push notifications) are in Mobile and Native Clients.

Version notes and limits

  • SignalR 2 versus ASP.NET Core SignalR. The .NET Framework library and ASP.NET Core SignalR are incompatible in both protocol and API. A 2.x client cannot talk to a Core hub or the reverse. See SignalR 2.

  • Stateful reconnect arrived in .NET 8 and is off by default.

  • Client results arrived in .NET 7.

  • Native AOT. SignalR has partial Native AOT support in .NET 10: use the JSON protocol with source-generated serializers, and avoid strongly typed hubs and struct element types in IAsyncEnumerable<T> or ChannelReader<T>.

  • .NET 11 (release candidate 2). Adds authentication refresh and cancellation of non-streaming invocations (a CancellationToken parameter on any hub method). Treat these as preview until the GA release; the runnable example here targets .NET 10 LTS.

  • Support. ASP.NET Core 10 is the LTS line; plan upgrades from the .NET support policy page below.

Summary

SignalR is the shortest path to a scalable real-time feature in a .NET system. Its costs are its own protocol on the wire, state you must keep outside the hub, and operational rules (sticky sessions, backplane limits, token handling). The Chatterbox example shows the pattern that works: carry your own envelopes through one hub method, keep durable state in Redis, use the backplane only for fan-out, and let the client rebuild its rooms after every reconnect. Continue with Building a Chat App to compare this server with the other stacks, and with Comparison and Decision Guide.

References