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.Userreaches every tab of one person,OnDisconnectedAsyncdrives 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 |
2 |
StreamItem |
One item of a stream, tied to an |
3 |
Completion |
End of an invocation or stream: carries a result, an error, or nothing. |
4 |
StreamInvocation |
Start a server-to-client stream ( |
5 |
CancelInvocation |
Cancel a running stream. |
6 |
Ping |
Keep-alive, |
7 |
Close |
Orderly close, optionally with an error and an |
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.
-
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. -
Calls from one connection run one at a time.
MaximumParallelInvocationsPerClientdefaults 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. -
Await every send. A call such as
Clients.All.SendAsync(...)that is not awaited can fail after the hub method has already completed. -
Groups are not persisted. A connection that reconnects has a new
ConnectionIdand 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 |
|---|---|---|
|
15 s |
Server ping interval when the server has sent nothing else. |
|
30 s |
The server drops a client it has heard nothing from for this long; keep it at twice the keep-alive interval. |
|
15 s |
How long the server waits for the client’s handshake. |
|
32 KB |
Largest incoming hub message; a larger one aborts the connection. |
|
1 |
Hub calls of one client that run at once before queuing. |
|
10 |
Items buffered for a client-to-server stream. |
|
false |
Send full exception messages to clients. Leave off in production. |
|
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 |
Requested in the handshake |
Not used. SignalR negotiates |
One envelope per frame |
One text frame |
One |
|
Close 1009 or 4413 |
|
Application close codes 44xx |
Sent in the close frame |
SignalR cannot send custom close codes. The server sends an |
Hello within 5 s (4408) |
Timer closes the socket |
|
Heartbeat (ping every |
WebSocket ping |
SignalR keep-alive: 20 s interval and 40 s timeout, set to match |
Per-connection rate limit |
Token bucket |
The same token bucket, stored in |
Origin allow-list |
|
CORS for negotiate plus |
Resume with |
Server keeps session 120 s |
Not implemented: |
Ordering by |
Single socket loop |
One invocation at a time per connection, plus a Redis |
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.AllowedOriginsand remember that a non-browser client can send anyOrigin; the check is protection against cross-site hijacking by a browser, not authentication. CORS for SignalR needsGET,POSTandDELETEand 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
MaximumReceiveMessageSizesmall, set it per hub withAddHubOptions<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
HubExceptionfor text you want the client to see, and leaveEnableDetailedErrorsoff outside development. The example maps expected failures to Chatterboxerrorenvelopes 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.
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. Registeronhandlers first. -
Method name mismatch. The server calls the client method named
frameand 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.OthersorCallerfromIHubContext. 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
MaximumReceiveMessageSizethe 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>orChannelReader<T>. -
.NET 11 (release candidate 2). Adds authentication refresh and cancellation of non-streaming invocations (a
CancellationTokenparameter 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
-
Microsoft Learn: Authentication and authorization in ASP.NET Core SignalR
-
Microsoft Learn: Security considerations in ASP.NET Core SignalR
-
Microsoft Learn: ASP.NET Core SignalR production hosting and scaling
-
Microsoft Learn: Set up a Redis backplane for ASP.NET Core SignalR scale-out
-
Microsoft Learn: Use MessagePack Hub Protocol in SignalR for ASP.NET Core
-
Microsoft Learn: Logging and diagnostics in ASP.NET Core SignalR
-
Microsoft Learn: Differences between ASP.NET SignalR and ASP.NET Core SignalR