.NET & C# Development · Lesson 219 of 229
SignalR Deep Dive — Real-Time Patterns in .NET 9
SignalR Deep Dive — Real-Time Patterns in .NET 9
SignalR abstracts WebSockets, Server-Sent Events, and long polling into a single API. For most .NET teams it is the right tool for real-time features — but the default configuration works for demos, not production. This article covers the decisions that determine whether SignalR holds up under load.
What you'll build:
- Typed hub with group management
- Redis backplane for horizontal scaling
- Reconnection with message replay
- Connection lifecycle events
- Authentication and per-connection authorization
- Performance tuning for high-concurrency scenarios
When SignalR, When Not
SignalR is the right choice when:
- You need server → client push (notifications, live dashboards, collaborative editing)
- You need bidirectional communication (chat, multiplayer, live support)
- Your clients are browsers or .NET apps
Consider alternatives when:
- You only need client → server (use HTTP)
- You need very high fan-out to millions of clients (use a dedicated push service: Azure Web PubSub, Pusher)
- You need message ordering guarantees across restarts (use Kafka + SSE)
1. Typed Hubs
An untyped hub uses string method names at runtime. A typed hub generates compile-time-safe client proxy interfaces — the compiler catches method name typos and signature mismatches.
Define the client interface
// Hubs/INotificationClient.cs
public interface INotificationClient
{
Task ReceiveNotification(NotificationDto notification);
Task ReceiveSystemAlert(SystemAlertDto alert);
Task UserJoined(string userId, string displayName);
Task UserLeft(string userId);
Task DocumentUpdated(string documentId, DocumentPatchDto patch);
Task ConnectionAcknowledged(string connectionId, DateTimeOffset serverTime);
}
public record NotificationDto(string Id, string Title, string Body, string Type, DateTimeOffset CreatedAt);
public record SystemAlertDto(string Level, string Message);
public record DocumentPatchDto(int Version, string PatchJson, string AuthorId);Implement the hub
// Hubs/NotificationHub.cs
[Authorize]
public class NotificationHub : Hub<INotificationClient>
{
private readonly IConnectionTracker _tracker;
private readonly IMessageReplayStore _replayStore;
private readonly ILogger<NotificationHub> _logger;
public NotificationHub(
IConnectionTracker tracker,
IMessageReplayStore replayStore,
ILogger<NotificationHub> logger)
{
_tracker = tracker;
_replayStore = replayStore;
_logger = logger;
}
// Called when a client connects
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier!;
var tenantId = Context.User!.FindFirst("tenant_id")!.Value;
// Track connection
await _tracker.RegisterAsync(Context.ConnectionId, userId, tenantId);
// Join tenant group (all connections for this tenant)
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
// Join user group (all connections for this user — multi-tab)
await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");
// Acknowledge connection
await Clients.Caller.ConnectionAcknowledged(Context.ConnectionId, DateTimeOffset.UtcNow);
// Replay missed messages since last disconnect
var lastSeen = await _tracker.GetLastSeenAsync(userId);
if (lastSeen.HasValue)
{
var missed = await _replayStore.GetSinceAsync(userId, lastSeen.Value);
foreach (var msg in missed)
await Clients.Caller.ReceiveNotification(msg);
}
await base.OnConnectedAsync();
}
// Called when a client disconnects
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.UserIdentifier!;
await _tracker.RecordLastSeenAsync(userId, DateTimeOffset.UtcNow);
await _tracker.UnregisterAsync(Context.ConnectionId);
if (exception is not null)
_logger.LogWarning(exception, "Connection {ConnectionId} disconnected with error", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
// Client-callable hub method
public async Task JoinDocument(string documentId)
{
var tenantId = Context.User!.FindFirst("tenant_id")!.Value;
// Verify the user has access to this document before joining
await Groups.AddToGroupAsync(Context.ConnectionId, $"doc:{tenantId}:{documentId}");
}
public async Task LeaveDocument(string documentId)
{
var tenantId = Context.User!.FindFirst("tenant_id")!.Value;
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"doc:{tenantId}:{documentId}");
}
public async Task SendDocumentPatch(string documentId, string patchJson)
{
var userId = Context.UserIdentifier!;
var tenantId = Context.User!.FindFirst("tenant_id")!.Value;
var patch = new DocumentPatchDto(
Version: await _replayStore.NextVersionAsync(documentId),
PatchJson: patchJson,
AuthorId: userId
);
// Broadcast to all users viewing this document except the sender
await Clients
.GroupExcept($"doc:{tenantId}:{documentId}", Context.ConnectionId)
.DocumentUpdated(documentId, patch);
}
}Register the hub
// Program.cs
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.MaximumReceiveMessageSize = 32 * 1024; // 32 KB max message
options.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
options.HandshakeTimeout = TimeSpan.FromSeconds(15);
});
// ...
app.MapHub<NotificationHub>("/hubs/notifications");2. Server-Side Push (Outside the Hub)
Most real-time events originate outside the hub — a background job completes, a database row changes, a message arrives on a queue. Inject IHubContext<THub, TClient> to push from anywhere.
// Services/NotificationDispatcher.cs
public class NotificationDispatcher
{
private readonly IHubContext<NotificationHub, INotificationClient> _hub;
private readonly IMessageReplayStore _replayStore;
public NotificationDispatcher(
IHubContext<NotificationHub, INotificationClient> hub,
IMessageReplayStore replayStore)
{
_hub = hub;
_replayStore = replayStore;
}
// Send to a specific user (all their connections/tabs)
public async Task SendToUserAsync(string userId, NotificationDto notification, CancellationToken ct = default)
{
await _replayStore.StoreAsync(userId, notification);
await _hub.Clients.Group($"user:{userId}").ReceiveNotification(notification);
}
// Broadcast to all users in a tenant
public async Task SendToTenantAsync(string tenantId, SystemAlertDto alert, CancellationToken ct = default)
{
await _hub.Clients.Group($"tenant:{tenantId}").ReceiveSystemAlert(alert);
}
// Send to users viewing a specific document
public async Task SendDocumentUpdateAsync(string tenantId, string documentId, DocumentPatchDto patch)
{
await _hub.Clients
.Group($"doc:{tenantId}:{documentId}")
.DocumentUpdated(documentId, patch);
}
}// Background worker that processes a queue and pushes updates
public class OrderStatusWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var statusChange in _orderQueue.ReadAllAsync(ct))
{
using var scope = _scopeFactory.CreateScope();
var dispatcher = scope.ServiceProvider.GetRequiredService<NotificationDispatcher>();
await dispatcher.SendToUserAsync(statusChange.CustomerId, new NotificationDto(
Id: Guid.NewGuid().ToString(),
Title: "Order Update",
Body: $"Your order #{statusChange.OrderId} is now {statusChange.Status}",
Type: "order_status",
CreatedAt: DateTimeOffset.UtcNow
), ct);
}
}
}3. Redis Backplane for Horizontal Scaling
A single SignalR server instance stores group memberships and connections in memory. When you run two instances behind a load balancer, a client on instance A cannot receive messages sent from instance B. The Redis backplane synchronises messages across all instances.
dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis// Program.cs
builder.Services
.AddSignalR()
.AddStackExchangeRedis(
builder.Configuration.GetConnectionString("Redis")!,
options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("myapp");
options.Configuration.ConnectRetry = 5;
options.Configuration.ReconnectRetryPolicy = new LinearRetry(1000);
});What the backplane does: When you call Clients.Group("tenant:abc").ReceiveNotification(...), SignalR publishes that message to a Redis Pub/Sub channel. Every other instance subscribed to that channel receives it and forwards it to its locally-connected clients.
What the backplane does NOT do: It does not store group membership. Groups are per-instance in-memory. If an instance restarts, its clients reconnect and must re-join their groups via OnConnectedAsync. This is why OnConnectedAsync must always add the connection to all relevant groups — it runs on every connection, including reconnects.
Redis connection resilience
// Use a shared IConnectionMultiplexer for health checks + backplane
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
var config = ConfigurationOptions.Parse(
builder.Configuration.GetConnectionString("Redis")!);
config.AbortOnConnectFail = false;
config.ConnectRetry = 5;
config.ReconnectRetryPolicy = new ExponentialRetry(1000, 30_000);
return ConnectionMultiplexer.Connect(config);
});4. Authentication and Authorization
JWT Bearer authentication for SignalR
Browser WebSocket connections cannot send custom headers. SignalR passes the access token as a query string parameter (?access_token=...). Configure the JWT handler to read from query string for hub paths:
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
// SignalR: read token from query string for WebSocket connections
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});Map user identity to connection
SignalR uses IUserIdProvider to determine which user identifier to assign to each connection. The default uses ClaimTypes.NameIdentifier. Override it to use a different claim:
// Hubs/TenantUserIdProvider.cs
public class TenantUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection)
{
// Use the "sub" claim (standard OIDC subject identifier)
return connection.User?.FindFirst("sub")?.Value;
}
}
// Program.cs
builder.Services.AddSingleton<IUserIdProvider, TenantUserIdProvider>();With this configured, Context.UserIdentifier in the hub returns the sub claim value, and Clients.User(userId) targets all connections for that user.
Per-method authorization
[Authorize]
public class NotificationHub : Hub<INotificationClient>
{
// Requires authentication (from class-level attribute)
public async Task JoinDocument(string documentId) { ... }
// Requires a specific role
[Authorize(Roles = "Admin")]
public async Task BroadcastSystemAlert(string message)
{
await Clients.All.ReceiveSystemAlert(new SystemAlertDto("warning", message));
}
// Custom policy
[Authorize(Policy = "CanEditDocuments")]
public async Task SendDocumentPatch(string documentId, string patchJson) { ... }
}5. Client Reconnection and Message Replay
The default SignalR JavaScript client retries with a fixed delay. For production, use a custom retry policy and replay missed messages after reconnect.
TypeScript client with reconnection
// lib/signalr-client.ts
import * as signalR from "@microsoft/signalr";
export function createHubConnection(token: string): signalR.HubConnection {
return new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications", {
accessTokenFactory: () => token,
})
.withAutomaticReconnect({
// Custom delays: 0, 2, 5, 10, 30 seconds, then 30s intervals
nextRetryDelayInMilliseconds: (retryContext) => {
const delays = [0, 2000, 5000, 10000, 30000];
return delays[retryContext.previousRetryCount] ?? 30000;
},
})
.configureLogging(
process.env.NODE_ENV === "development"
? signalR.LogLevel.Information
: signalR.LogLevel.Warning
)
.build();
}// hooks/useNotifications.ts (React)
export function useNotifications() {
const { token } = useAuth();
const connectionRef = useRef<signalR.HubConnection | null>(null);
useEffect(() => {
const connection = createHubConnection(token);
connection.on("ReceiveNotification", (notification: NotificationDto) => {
notificationStore.add(notification);
});
connection.onreconnecting((error) => {
console.warn("SignalR reconnecting:", error?.message);
setConnectionState("reconnecting");
});
connection.onreconnected((connectionId) => {
console.info("SignalR reconnected:", connectionId);
setConnectionState("connected");
// Re-join document groups after reconnect
if (currentDocumentId) {
connection.invoke("JoinDocument", currentDocumentId);
}
});
connection.onclose((error) => {
console.error("SignalR connection closed:", error?.message);
setConnectionState("disconnected");
});
connection.start()
.then(() => setConnectionState("connected"))
.catch(console.error);
connectionRef.current = connection;
return () => {
connection.stop();
};
}, [token]);
return connectionRef;
}Server-side message replay store
// Services/MessageReplayStore.cs
public class MessageReplayStore : IMessageReplayStore
{
private readonly IDatabase _redis;
private static readonly TimeSpan MessageTtl = TimeSpan.FromHours(24);
public MessageReplayStore(IConnectionMultiplexer redis)
{
_redis = redis.GetDatabase();
}
public async Task StoreAsync(string userId, NotificationDto notification)
{
var key = $"replay:{userId}";
var json = JsonSerializer.Serialize(notification);
// Store with score = epoch seconds for time-based range queries
await _redis.SortedSetAddAsync(key,
json,
DateTimeOffset.UtcNow.ToUnixTimeSeconds());
// Expire the key to cap storage
await _redis.KeyExpireAsync(key, MessageTtl);
}
public async Task<List<NotificationDto>> GetSinceAsync(string userId, DateTimeOffset since)
{
var key = $"replay:{userId}";
var entries = await _redis.SortedSetRangeByScoreAsync(
key,
min: since.ToUnixTimeSeconds(),
max: double.PositiveInfinity);
return entries
.Select(e => JsonSerializer.Deserialize<NotificationDto>(e!)!)
.ToList();
}
public async Task<int> NextVersionAsync(string documentId)
{
return (int)await _redis.StringIncrementAsync($"doc-version:{documentId}");
}
}6. Connection Tracking
Knowing which users are online is a common requirement. Track it in Redis so all instances share state:
// Services/ConnectionTracker.cs
public class ConnectionTracker : IConnectionTracker
{
private readonly IDatabase _redis;
private static readonly TimeSpan OnlineTtl = TimeSpan.FromMinutes(2);
public ConnectionTracker(IConnectionMultiplexer redis)
{
_redis = redis.GetDatabase();
}
public async Task RegisterAsync(string connectionId, string userId, string tenantId)
{
var batch = _redis.CreateBatch();
// Map connection → user
_ = batch.StringSetAsync($"conn:{connectionId}", userId, OnlineTtl);
// Add to tenant's online set
_ = batch.SetAddAsync($"online:{tenantId}", userId);
// Heartbeat key — expires if client goes silent
_ = batch.StringSetAsync($"heartbeat:{userId}", connectionId, OnlineTtl);
batch.Execute();
await Task.CompletedTask;
}
public async Task UnregisterAsync(string connectionId)
{
var userId = await _redis.StringGetAsync($"conn:{connectionId}");
if (!userId.IsNull)
{
await _redis.KeyDeleteAsync($"conn:{connectionId}");
await _redis.KeyDeleteAsync($"heartbeat:{userId!}");
}
}
public async Task RecordLastSeenAsync(string userId, DateTimeOffset time)
{
await _redis.StringSetAsync(
$"last-seen:{userId}",
time.ToUnixTimeSeconds().ToString(),
TimeSpan.FromDays(30));
}
public async Task<DateTimeOffset?> GetLastSeenAsync(string userId)
{
var val = await _redis.StringGetAsync($"last-seen:{userId}");
if (val.IsNull) return null;
return DateTimeOffset.FromUnixTimeSeconds(long.Parse(val!));
}
public async Task<bool> IsOnlineAsync(string userId)
{
return await _redis.KeyExistsAsync($"heartbeat:{userId}");
}
public async Task<HashSet<string>> GetOnlineUsersAsync(string tenantId)
{
var members = await _redis.SetMembersAsync($"online:{tenantId}");
var online = new HashSet<string>();
foreach (var m in members)
{
if (await IsOnlineAsync(m!))
online.Add(m!);
else
await _redis.SetRemoveAsync($"online:{tenantId}", m); // cleanup
}
return online;
}
}7. Performance Tuning
MessagePack over JSON
SignalR serialises messages as JSON by default. MessagePack is binary, roughly 2-3x smaller, and faster to serialise/deserialise — important at high message rates.
dotnet add package Microsoft.AspNetCore.SignalR.Protocols.MessagePackbuilder.Services
.AddSignalR()
.AddMessagePackProtocol();// Client must also use MessagePack
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications")
.withHubProtocol(new MessagePackHubProtocol())
.build();Streaming for large or continuous data
Instead of sending one large payload, stream it:
// Hub method returning a stream
public async IAsyncEnumerable<AuditEventDto> StreamAuditLog(
string tenantId,
DateTimeOffset from,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var evt in _auditLog.GetEventsAsync(tenantId, from, ct))
{
yield return evt;
}
}// Client subscribes to the stream
const stream = connection.stream<AuditEventDto>("StreamAuditLog", tenantId, fromDate);
stream.subscribe({
next: (event) => auditLog.push(event),
error: (err) => console.error(err),
complete: () => console.log("Stream complete"),
});Limit concurrent connections per user
Prevent a single user from opening hundreds of tabs and consuming all server resources:
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier!;
var connections = await _tracker.GetConnectionCountAsync(userId);
if (connections > 10)
{
Context.Abort(); // close this new connection
return;
}
await base.OnConnectedAsync();
}8. Health Check for SignalR
Verify that the Redis backplane is reachable and SignalR can route messages:
// HealthChecks/SignalRBackplaneHealthCheck.cs
public class SignalRBackplaneHealthCheck : IHealthCheck
{
private readonly IConnectionMultiplexer _redis;
public SignalRBackplaneHealthCheck(IConnectionMultiplexer redis)
{
_redis = redis;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken ct = default)
{
try
{
await _redis.GetDatabase().PingAsync();
return HealthCheckResult.Healthy("SignalR Redis backplane is reachable");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("SignalR backplane unreachable", ex);
}
}
}
// Program.cs
builder.Services.AddHealthChecks()
.AddCheck<SignalRBackplaneHealthCheck>("signalr-backplane", tags: ["ready"]);Common Mistakes
Storing state in hub instance variables. Hub instances are created per-method call, not per connection. Never store connection-specific data in hub fields — use Context.Items or an injected scoped service.
Calling hub methods from a static context. You cannot access Context or Clients from a static method. Push from outside hubs using IHubContext.
Not re-joining groups after reconnect. Groups are in-memory per instance. OnConnectedAsync fires on every connect, including automatic reconnects — always add the connection to all relevant groups there.
Forgetting the backplane when scaling. A single-instance load test works fine; a two-instance load test silently loses half the messages. Test with multiple instances from day one if you plan to scale.
Using Clients.All for tenant-scoped messages. Broadcasting to all connected clients leaks data across tenants. Always scope broadcasts to a tenant group: Clients.Group($"tenant:{tenantId}").