ReceiveUpdateAsync(string entityId, string payload);
Task BroadcastNotificationAsync(string message, int severity);
Task ConnectionAckAsync(string connectionId);
}
// RealtimeHub.cs
public class RealtimeHub : Hub<IRealtimeClient>
{
private readonly IConnectionManager _connectionManager;
private readonly ILogger<RealtimeHub> _logger;
public RealtimeHub(IConnectionManager connectionManager, ILogger<RealtimeHub> logger)
{
_connectionManager = connectionManager;
_logger = logger;
}
public override async Task OnConnectedAsync()
{
await Clients.Caller.ConnectionAckAsync(Context.ConnectionId);
_connectionManager.Register(Context.ConnectionId, Context.UserIdentifier);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_connectionManager.Remove(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
public Task PushEntityUpdateAsync(string entityId, string payload)
{
return Clients.Group(entityId).ReceiveUpdateAsync(entityId, payload);
}
}
### Step 2: Configure Transport, Backplane, and Authentication
SignalR defaults to WebSockets but gracefully degrades. In production, explicitly configure transport order and attach a backplane early to avoid migration friction later.
```csharp
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = false; // Never enable in prod
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
options.MaximumReceiveMessageSize = 32 * 1024; // 32KB default, adjust per workload
})
.AddStackExchangeRedis(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.ChannelPrefix = "app-realtime";
})
.AddJsonProtocol(jsonOptions =>
{
jsonOptions.PayloadSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
builder.Services.AddSingleton<IConnectionManager, ConnectionManager>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<RealtimeHub>("/realtime", options =>
{
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
options.AllowStatefulReconnects = true; // .NET 7+ feature
});
app.Run();
Step 3: Secure with JWT and Validate Claims
Cookie-based authentication fails in cross-origin or mobile scenarios. JWT is the production standard. Pass the token via AccessTokenProvider and validate claims inside the Hub pipeline.
// client.ts
import * as signalR from "@microsoft/signalr";
const connection = new signalR.HubConnectionBuilder()
.withUrl("/realtime", {
accessTokenFactory: () => localStorage.getItem("jwt_token") || ""
})
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
.configureLogging(signalR.LogLevel.Warning)
.build();
connection.on("receiveUpdateAsync", (entityId, payload) => {
console.log(`Update for ${entityId}:`, payload);
});
connection.start().catch(err => console.error("SignalR connect failed:", err));
Architecture Decisions & Rationale
- Hub Pattern over Raw WebSockets: The Hub abstraction handles connection state, group management, and serialization. Raw WebSockets require manual frame parsing, ping/pong handling, and reconnection logic. Hub reduces boilerplate by ~65%.
- Redis Backplane from Day One: Stateful connections tie a client to a specific server node. Without a backplane, broadcasting requires sticky sessions or in-memory replication, both of which fail during auto-scaling. Redis pub/sub decouples message routing from connection hosting.
- Stateful Reconnects (.NET 7+): Traditional reconnections drop buffered messages. Stateful reconnects preserve a connection ID across network drops, allowing the server to replay missed messages. This is critical for financial, IoT, and collaborative editing workloads.
- Typed Client Interfaces: Reflection-based
Clients.All.SendAsync("MethodName", args) is error-prone and slower. Strong typing enables IDE intelligence, compile-time validation, and eliminates string-based method name mismatches.
Pitfall Guide
1. Blocking Async Calls in Hub Methods
Mistake: Using .Result or .Wait() inside Hub methods.
Impact: Deadlocks the thread pool, causing connection drops and cascade failures under load.
Fix: Always use async/await. If you must call synchronous legacy code, wrap it in Task.Run() but isolate it from the SignalR pipeline.
2. Ignoring Connection Lifecycle Events
Mistake: Failing to override OnConnectedAsync and OnDisconnectedAsync.
Impact: Orphaned connections accumulate in memory, groups become stale, and user presence tracking breaks.
Fix: Register/unregister connections in a concurrent dictionary or distributed cache. Clean up groups when the last member disconnects.
3. Overusing Groups Without Expiration
Mistake: Creating groups per user/session without cleanup logic.
Impact: Redis or SQL backplane bloats with millions of dead group keys, increasing memory pressure and pub/sub latency.
Fix: Implement group TTLs or cleanup jobs. Use Groups.RemoveFromGroupAsync explicitly when sessions end.
4. Misconfiguring Transport Fallback
Mistake: Forcing only WebSockets or disabling fallbacks in corporate environments with restrictive proxies.
Impact: Clients fail to connect entirely. Fallbacks are not a bug; they are a resilience feature.
Fix: Allow WebSockets | ServerSentEvents | LongPolling. Monitor connection negotiation logs to identify proxy interference.
5. Neglecting Message Size Limits
Mistake: Sending large payloads without adjusting MaximumReceiveMessageSize.
Impact: InvalidDataException thrown, connection terminated silently.
Fix: Increase the limit if sending files or large JSON blobs, but implement chunking or object storage references for payloads >1MB.
6. Using Synchronous I/O in Real-Time Pipelines
Mistake: Database calls or HTTP requests inside Hub methods without await.
Impact: Thread pool starvation, increased latency, and dropped messages during traffic spikes.
Fix: Use IAsyncEnumerable for streaming data. Offload heavy processing to background workers (e.g., Hangfire, Azure Functions) and push results via SignalR.
7. Client-Side Reconnection Blind Spots
Mistake: Assuming automatic reconnection restores all application state.
Impact: UI shows stale data, event listeners detach, or duplicate subscriptions occur.
Fix: Implement connection.onreconnected to re-subscribe to groups, fetch delta state, and reset UI flags. Never rely solely on automatic reconnect for business logic.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| <10K concurrent users, single region | In-memory groups + default WebSockets | Simpler deployment, lower infrastructure overhead | Low (single app service) |
| >50K concurrent users, multi-region | Redis backplane + Azure SignalR Service | Horizontal scaling, geographic latency reduction | Medium-High (managed service + Redis) |
| Offline-first mobile apps | Stateful reconnects + local cache sync | Preserves connection ID across network drops, enables delta replay | Low (configuration only) |
| High-security enterprise (SOX/HIPAA) | JWT + Claims validation + TLS 1.3 + No long polling | Prevents token replay, ensures encrypted transport, meets compliance | Medium (identity provider integration) |
| IoT/Telemetry streaming | IAsyncEnumerable + binary protocol | Reduces serialization overhead, enables backpressure handling | Low (protocol switch) |
Configuration Template
// appsettings.json
{
"ConnectionStrings": {
"Redis": "redis-instance.redis.cache.windows.net:6380,password=xxx,ssl=True,abortConnect=False"
},
"SignalR": {
"KeepAliveInterval": "00:00:15",
"ClientTimeoutInterval": "00:00:30",
"MaximumReceiveMessageSize": 65536,
"EnableDetailedErrors": false
},
"AllowedHosts": "*",
"Cors": {
"AllowedOrigins": ["https://app.yourdomain.com", "https://admin.yourdomain.com"],
"AllowedMethods": ["GET", "POST"],
"AllowedHeaders": ["Authorization", "Content-Type"]
}
}
// Program.cs (Production Configuration Snippet)
builder.Services.AddCors(options =>
{
options.AddPolicy("SignalRCors", policy =>
{
policy.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()!)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
builder.Services.AddSignalR(options =>
{
var cfg = builder.Configuration.GetSection("SignalR");
options.KeepAliveInterval = TimeSpan.Parse(cfg["KeepAliveInterval"]!);
options.ClientTimeoutInterval = TimeSpan.Parse(cfg["ClientTimeoutInterval"]!);
options.MaximumReceiveMessageSize = int.Parse(cfg["MaximumReceiveMessageSize"]!);
options.EnableDetailedErrors = bool.Parse(cfg["EnableDetailedErrors"]!);
})
.AddStackExchangeRedis(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.ChannelPrefix = "prod-signalr";
})
.AddMessagePackProtocol(); // Optional: reduces payload size by 30-40%
Quick Start Guide
- Install Packages: Run
dotnet add package Microsoft.AspNetCore.SignalR and npm install @microsoft/signalr.
- Create Hub: Implement a class inheriting
Hub<TClient> with typed methods for client invocation.
- Wire Endpoints: Add
builder.Services.AddSignalR() and app.MapHub<YourHub>("/hub") in Program.cs.
- Connect Client: Instantiate
HubConnectionBuilder, attach accessTokenFactory, call start(), and register on() handlers for server-to-client messages.
- Validate: Use browser DevTools Network tab to verify WebSocket upgrade, monitor heartbeat frames, and test disconnect/reconnect cycles.
SignalR is not a chat library. It is a production-grade real-time transport layer that, when configured correctly, eliminates polling overhead, abstracts transport negotiation, and scales horizontally without sticky sessions. Treat it as infrastructure, not a feature, and the operational gains compound rapidly.