ompile-time checking
public ChatHub(ILogger<ChatHub> logger, IUserRepository userRepository)
{
_logger = logger;
_userRepository = userRepository;
}
public override async Task OnConnectedAsync()
{
var userId = Context.GetUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, $"User_{userId}");
await Clients.All.UserJoined(userId);
_logger.LogInformation("Connection {ConnectionId} established for User {UserId}",
Context.ConnectionId, userId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.GetUserId();
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"User_{userId}");
await base.OnDisconnectedAsync(exception);
}
public async Task SendMessage(string user, string message)
{
if (string.IsNullOrWhiteSpace(message))
throw new HubException("Message cannot be empty.");
// Validate payload size to prevent abuse
if (message.Length > 4096)
throw new HubException("Message exceeds maximum length.");
await Clients.All.ReceiveMessage(user, message);
}
}
### 2. Service Configuration
Register SignalR services and configure limits, authentication, and scaling options.
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR(options =>
{
// Security and performance limits
options.MaximumReceiveMessageSize = 32 * 1024; // 32KB
options.EnableDetailedErrors = false; // Disable in production
options.StreamBufferCapacity = 10;
// Client timeout configuration
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
options.HandshakeTimeout = TimeSpan.FromSeconds(15);
})
.AddMessagePackProtocol() // High-performance binary serialization
.AddStackExchangeRedis(config =>
{
config.Configuration = builder.Configuration.GetConnectionString("Redis");
config.ConfigurationChannel = "SignalRChannel";
}); // Scale-out backplane
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Events = new JwtBearerEvents
{
// SignalR requires token handling in query string
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;
}
};
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<ChatHub>("/hubs/chat");
app.Run();
3. Client Implementation (TypeScript)
The client should handle reconnection and connection state management.
import * as signalR from "@microsoft/signalr";
export class ChatService {
private connection: signalR.HubConnection;
constructor() {
this.connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/chat", {
accessTokenFactory: () => localStorage.getItem("authToken") || ""
})
.withAutomaticReconnect([0, 2000, 5000, 10000, 15000])
.withHubProtocol(new signalR.MessagePackHubProtocol())
.build();
this.connection.on("ReceiveMessage", (user: string, message: string) => {
console.log(`${user}: ${message}`);
});
this.connection.onreconnecting(error => {
console.warn("Connection lost. Reconnecting...", error);
});
this.connection.onreconnected(connectionId => {
console.log(`Reconnected. ConnectionId: ${connectionId}`);
});
}
async start(): Promise<void> {
try {
await this.connection.start();
console.log("Connected to SignalR Hub.");
} catch (err) {
console.error("Connection failed:", err);
setTimeout(() => this.start(), 5000);
}
}
async sendMessage(user: string, message: string): Promise<void> {
await this.connection.invoke("SendMessage", user, message);
}
}
4. Architecture Decisions
- Strongly-Typed Clients: Define interfaces for client methods to enforce contracts and leverage IDE tooling.
- MessagePack Protocol: Switch from JSON to MessagePack for binary serialization. This reduces payload size by ~40% and improves serialization speed, critical for high-throughput scenarios.
- Backplane Strategy: For single-instance deployments, in-memory is sufficient. For multi-instance, a backplane (Redis, Azure SignalR Service) is mandatory to synchronize state across nodes.
- External Triggers: Use
IHubContext<T> to send messages from outside the Hub, such as background services or controllers.
public class NotificationService
{
private readonly IHubContext<ChatHub, IChatClient> _hubContext;
public NotificationService(IHubContext<ChatHub, IChatClient> hubContext)
{
_hubContext = hubContext;
}
public async Task BroadcastAlert(string alert)
{
await _hubContext.Clients.All.ReceiveMessage("System", alert);
}
}
Pitfall Guide
Production SignalR implementations frequently fail due to avoidable architectural and coding errors.
-
Blocking Hub Methods: Hub methods must be asynchronous. Using .Result or .Wait() blocks the underlying thread pool, causing deadlocks and connection timeouts.
- Correction: Always use
async/await. Return Task or Task<T>.
-
Ignoring Connection Lifecycle: Failing to override OnConnectedAsync and OnDisconnectedAsync leads to stale group memberships and memory leaks. Connections may persist in groups after the client drops, causing messages to be sent to dead connections.
- Correction: Implement cleanup logic in
OnDisconnectedAsync to remove connections from groups and update user status.
-
Fan-Out Explosion: Calling Clients.All or Clients.Group with a large number of connections without pagination or batching can overwhelm the backplane and the server network interface.
- Correction: For large broadcasts, consider chunking messages or using a dedicated pub/sub channel for massive fan-out, rather than relying solely on SignalR groups. Monitor group sizes; if groups exceed 10k connections, evaluate architectural alternatives.
-
Scoped Service Injection in Hubs: Injecting scoped services into a Hub resolves the service per connection. If the scoped service holds heavy resources or maintains state across the connection lifetime without disposal, it causes memory bloat.
- Correction: Prefer transient services or singleton services with careful state management. If scoped services are required, ensure they implement
IDisposable and are disposed when the connection closes.
-
Token Management Failures: JWT tokens expire. If the client does not refresh the token in the accessTokenFactory, the connection will drop and fail to reconnect.
- Correction: Implement token refresh logic in the client. The
accessTokenFactory should return the current valid token, triggering a refresh if necessary before the connection attempt.
-
Missing Message Size Limits: Without MaximumReceiveMessageSize, a malicious client can send massive payloads, causing OutOfMemory exceptions or excessive CPU usage during deserialization.
- Correction: Set
MaximumReceiveMessageSize to a value appropriate for your domain. Validate payload content before processing.
-
Neglecting Transport Fallbacks: Forcing WebSockets only can break connectivity for clients behind restrictive proxies or firewalls that block WebSocket upgrades.
- Correction: Allow SignalR's default transport negotiation. It will fallback to Server-Sent Events or Long Polling automatically. Only restrict transports if you have specific infrastructure guarantees.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal Tool / Single Node | In-Memory Backplane | Zero infrastructure overhead; simplest setup. | None |
| Multi-Region SaaS | Azure SignalR Service | Managed scale-out, auto-scaling, global distribution, no backplane management. | High |
| High-Throughput IoT | Redis Backplane + MessagePack | Low latency, high throughput, cost-effective scale-out. | Medium |
| Intermittent Network | SignalR with Long Polling Fallback | Ensures connectivity where WebSockets are blocked or unstable. | Low |
| Strict Security Compliance | Custom Backplane (On-Prem Redis) | Data residency control; avoids cloud-managed services. | Medium/High |
Configuration Template
appsettings.json configuration for production-grade SignalR.
{
"SignalR": {
"MaximumReceiveMessageSize": 32768,
"EnableDetailedErrors": false,
"ClientTimeoutInterval": "00:00:30",
"HandshakeTimeout": "00:00:15",
"KeepAliveInterval": "00:00:15"
},
"ConnectionStrings": {
"Redis": "localhost:6379,password=secure_password"
},
"Logging": {
"LogLevel": {
"Microsoft.AspNetCore.SignalR": "Warning",
"StackExchange.Redis": "Warning"
}
}
}
Program.cs production setup snippet.
builder.Services.AddSignalR(options =>
{
var signalROptions = builder.Configuration.GetSection("SignalR");
options.MaximumReceiveMessageSize = signalROptions.GetValue<int>("MaximumReceiveMessageSize");
options.EnableDetailedErrors = signalROptions.GetValue<bool>("EnableDetailedErrors");
options.ClientTimeoutInterval = signalROptions.GetValue<TimeSpan>("ClientTimeoutInterval");
options.HandshakeTimeout = signalROptions.GetValue<TimeSpan>("HandshakeTimeout");
options.KeepAliveInterval = signalROptions.GetValue<TimeSpan>("KeepAliveInterval");
})
.AddMessagePackProtocol()
.AddStackExchangeRedis(redis =>
{
redis.Configuration = builder.Configuration.GetConnectionString("Redis");
redis.ConfigurationChannel = "SignalRBackplane";
})
.AddJsonOptions(options =>
{
// Optimize JSON if fallback is used
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
Quick Start Guide
- Install Packages: Run
dotnet add package Microsoft.AspNetCore.SignalR and dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis.
- Create Hub: Define a class inheriting from
Hub<T> with strongly-typed client interface. Implement core methods.
- Register Services: Call
builder.Services.AddSignalR() in Program.cs. Configure backplane and limits.
- Map Endpoint: Use
app.MapHub<YourHub>("/hubs/yourhub") to expose the endpoint.
- Connect Client: Install
@microsoft/signalr via npm. Instantiate HubConnectionBuilder, configure URL and protocols, and call start().
ASP.NET Core SignalR provides a robust foundation for real-time communication. Success in production depends on rigorous configuration, awareness of connection lifecycle, and appropriate scaling strategies. Treat SignalR as a persistent infrastructure component, not a transient API endpoint.