Claude
Skill
dotnet-signalr
Implement or review SignalR hubs, streaming, reconnection, transport, and real-time delivery patterns in ASP.NET Core applications.
Virus-scanned
Reviewed automatically before listing.
Download
postpartum-genushyacinthus29-dotnet-skills-skills_dotnet-signalr-bfa4ebd.zip · 15 KB
Install
skills CLI
npx skills add https://github.com/Postpartum-genushyacinthus29/dotnet-skills/tree/main/skills/dotnet-signalr
Claude Code
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install postpartum-genushyacinthus29-dotnet-skills@llmmart
Git
git clone https://github.com/Postpartum-genushyacinthus29/dotnet-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole postpartum-genushyacinthus29/dotnet-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SignalR
Trigger On
- building chat, notification, collaboration, or live-update features
- debugging hub lifetime, connection state, or transport issues
- deciding whether SignalR or another transport better fits the scenario
- implementing real-time broadcasting to groups of connected clients
- scaling SignalR across multiple servers
Documentation
- ASP.NET Core SignalR Overview
- SignalR Hubs
- SignalR API Design Considerations
- SignalR Production Hosting and Scaling
- SignalR Configuration
References
- patterns.md - Detailed hub patterns, streaming, groups, presence, and advanced messaging techniques
- anti-patterns.md - Common SignalR mistakes and how to avoid them
Workflow
- Use SignalR for broadcast-style or connection-oriented real-time features; do not force gRPC into hub-style fan-out scenarios.
- Model hub contracts intentionally and keep hub methods thin, delegating durable work elsewhere.
- Plan for reconnection, backpressure, auth, and fan-out costs instead of treating real-time messaging as stateless request/response.
- Use groups, presence, and connection metadata deliberately so scale-out behavior is understandable.
- If Native AOT or trimming is in play, validate supported protocols and serialization choices explicitly.
- Test connection behavior and failure modes, not just happy-path message delivery.
Hub Patterns
Strongly-Typed Hub (Recommended)
// Define the client interface
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
Task UserJoined(string user);
Task UserLeft(string user);
}
// Implement the strongly-typed hub
public class ChatHub : Hub<IChatClient>
{
public async Task SendMessage(string user, string message)
{
// Compiler checks client method calls
await Clients.All.ReceiveMessage(user, message);
}
public override async Task OnConnectedAsync()
{
await Clients.Others.UserJoined(Context.User?.Identity?.Name ?? "Anonymous");
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
await Clients.Others.UserLeft(Context.User?.Identity?.Name ?? "Anonymous");
await base.OnDisconnectedAsync(exception);
}
}
Using Groups for Targeted Messaging
public class NotificationHub : Hub<INotificationClient>
{
public async Task JoinGroup(string groupName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
await Clients.Group(groupName).UserJoined(Context.User?.Identity?.Name);
}
public async Task LeaveGroup(string groupName)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
}
public async Task SendToGroup(string groupName, string message)
{
await Clients.Group(groupName).ReceiveNotification(message);
}
}
Hub Method with Custom Object Parameters (API Versioning)
// Use custom objects to avoid breaking changes
public class SendMessageRequest
{
public string Message { get; set; } = string.Empty;
public string? Recipient { get; set; } // Added later without breaking clients
public int? Priority { get; set; } // Added later without breaking clients
}
public class ChatHub : Hub<IChatClient>
{
public async Task SendMessage(SendMessageRequest request)
{
// Handle both old and new clients
if (request.Recipient != null)
{
await Clients.User(request.Recipient).ReceiveMessage(request.Message);
}
else
{
await Clients.All.ReceiveMessage(request.Message);
}
}
}
Client Patterns
JavaScript Client with Automatic Reconnection
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub")
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) // Retry delays
.configureLogging(signalR.LogLevel.Information)
.build();
// Handle reconnection events
connection.onreconnecting(error => {
console.log("Reconnecting...", error);
updateUIForReconnecting();
});
connection.onreconnected(connectionId => {
console.log("Reconnected with ID:", connectionId);
// Rejoin groups - reconnection does not restore group membership
rejoinGroups();
updateUIForConnected();
});
connection.onclose(error => {
console.log("Connection closed", error);
updateUIForDisconnected();
});
async function start() {
try {
await connection.start();
console.log("SignalR Connected");
} catch (err) {
console.log(err);
setTimeout(start, 5000);
}
}
start();
.NET Client with Reconnection
var connection = new HubConnectionBuilder()
.WithUrl("https://localhost:5001/chatHub", options =>
{
options.AccessTokenProvider = () => Task.FromResult(GetAccessToken());
})
.WithAutomaticReconnect()
.Build();
connection.Reconnecting += error =>
{
_logger.LogWarning("Connection lost. Reconnecting: {Error}", error?.Message);
return Task.CompletedTask;
};
connection.Reconnected += connectionId =>
{
_logger.LogInformation("Reconnected with ID: {ConnectionId}", connectionId);
// Rejoin groups after reconnection
return RejoinGroupsAsync();
};
connection.Closed += async error =>
{
_logger.LogError("Connection closed: {Error}", error?.Message);
await Task.Delay(Random.Shared.Next(0, 5) * 1000);
await connection.StartAsync();
};
await connection.StartAsync();
Server Configuration
Hub Registration with Authentication
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.MaximumReceiveMessageSize = 64 * 1024; // 64 KB
options.StreamBufferCapacity = 10;
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
})
.AddMessagePackProtocol(); // Binary protocol for performance
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// Read token from query string for WebSocket connections
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");
Sending Messages from Outside a Hub
public class NotificationService
{
private readonly IHubContext<NotificationHub, INotificationClient> _hubContext;
public NotificationService(IHubContext<NotificationHub, INotificationClient> hubContext)
{
_hubContext = hubContext;
}
public async Task NotifyAllAsync(string message)
{
await _hubContext.Clients.All.ReceiveNotification(message);
}
public async Task NotifyUserAsync(string userId, string message)
{
await _hubContext.Clients.User(userId).ReceiveNotification(message);
}
public async Task NotifyGroupAsync(string groupName, string message)
{
await _hubContext.Clients.Group(groupName).ReceiveNotification(message);
}
}
Scaling with Redis Backplane
builder.Services.AddSignalR()
.AddStackExchangeRedis(connectionString, options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp");
});
Anti-Patterns to Avoid
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| Storing state in Hub properties | Hub instances are created per method call | Use IMemoryCache, database, or external store |
| Instantiating Hub directly | Bypasses SignalR infrastructure | Use IHubContext<THub> for external messaging |
Not awaiting SendAsync calls |
Messages may not be sent before hub method completes | Always await async hub calls |
| Adding method parameters without versioning | Breaking change for existing clients | Use custom object parameters |
| Ignoring reconnection group loss | Clients lose group membership on reconnect | Re-add to groups in OnConnectedAsync or client reconnect handler |
| Large payloads over SignalR | Memory pressure, bandwidth issues | Use REST/gRPC for bulk data, SignalR for notifications |
| Missing backplane in multi-server | Messages only reach clients on same server | Use Redis backplane or Azure SignalR Service |
| Exposing ORM entities directly | May serialize sensitive data | Use DTOs with explicit properties |
| Not validating incoming messages | Security risk after initial auth | Validate every hub method input |
Best Practices
Connection Management
- Enable automatic reconnection with exponential backoff delays
- Handle group rejoining explicitly after reconnection (connection ID changes)
- Implement heartbeat monitoring on the client to detect stale connections
- Use sticky sessions when scaling across multiple servers (unless using Azure SignalR Service)
Performance
- Use MessagePack protocol for smaller message sizes and faster serialization
- Throttle high-frequency events like typing indicators or mouse movements
- Batch messages when possible instead of many small sends
- Set appropriate buffer sizes based on expected message throughput
Security
- Authenticate at connection time using JWT tokens via query string
- Authorize hub methods using
[Authorize]attribute - Validate all incoming messages even after authentication
- Use HTTPS for all SignalR connections
API Design
- Use strongly-typed hubs to catch client method name typos at compile time
- Use custom object parameters to enable backward-compatible API evolution
- Version hub names (e.g.,
ChatHubV2) for breaking changes - Keep hub methods thin and delegate business logic to services
Observability
- Log connection events (connect, disconnect, reconnect)
- Track transport type used by each connection
- Monitor message delivery latency and failure rates
- Integrate with Application Insights or other APM tools
Deliver
- clear hub contracts and connection behavior
- real-time delivery that matches the product scenario
- validation for reconnection and authorization flows
- appropriate scale-out strategy for multi-server deployments
Validate
- SignalR is the correct transport for the use case
- hub methods remain orchestration-oriented
- group and auth behavior are explicit and tested
- reconnection and group membership are handled correctly
- backplane is configured for multi-server scenarios
- message validation is implemented in hub methods
Files (dotnet-skills)
-
references
-
anti-patterns.md 19.5 KB
# SignalR Anti-Patterns This reference documents common SignalR mistakes and how to avoid them. ## Hub Instance Anti-Patterns ### Storing State in Hub Properties Hub instances are transient and created per invocation: ```csharp // BAD: State is lost between calls public class BadHub : Hub { private List<string> _messages = new(); // Created fresh every call private int _messageCount = 0; // Always 0 public Task SendMessage(string message) { _messages.Add(message); // Lost immediately _messageCount++; // Always 1 return Task.CompletedTask; } } // GOOD: Use external state management public class GoodHub : Hub { private readonly IMessageStore _store; private readonly IMemoryCache _cache; public GoodHub(IMessageStore store, IMemoryCache cache) { _store = store; _cache = cache; } public async Task SendMessage(string message) { await _store.AddMessageAsync(message); _cache.Set("lastMessage", message); } } // GOOD: Use Context.Items for per-connection state public class ConnectionStateHub : Hub { public Task SetConnectionData(string key, object value) { Context.Items[key] = value; return Task.CompletedTask; } public object? GetConnectionData(string key) { return Context.Items.TryGetValue(key, out var value) ? value : null; } } ``` ### Instantiating Hub Directly Never create hub instances manually: ```csharp // BAD: Bypasses SignalR infrastructure public class BadService { public async Task NotifyUsers() { var hub = new ChatHub(); // No context, no clients, no groups await hub.Clients.All.ReceiveMessage("test"); // NullReferenceException } } // GOOD: Use IHubContext<THub> public class GoodService { private readonly IHubContext<ChatHub, IChatClient> _hubContext; public GoodService(IHubContext<ChatHub, IChatClient> hubContext) { _hubContext = hubContext; } public async Task NotifyUsers() { await _hubContext.Clients.All.ReceiveMessage("test"); } } ``` ## Async Anti-Patterns ### Not Awaiting SendAsync Calls Fire-and-forget messaging loses errors and ordering: ```csharp // BAD: No await means method may complete before message is sent public class BadHub : Hub<IChatClient> { public Task SendMessage(string message) { Clients.All.ReceiveMessage(message); // Not awaited! return Task.CompletedTask; } } // BAD: Multiple unawaited calls have undefined ordering public class AlsoBadHub : Hub<IChatClient> { public Task NotifyAll() { Clients.All.Message1(); // Which arrives first? Clients.All.Message2(); // Unknown! return Task.CompletedTask; } } // GOOD: Always await async calls public class GoodHub : Hub<IChatClient> { public async Task SendMessage(string message) { await Clients.All.ReceiveMessage(message); } public async Task NotifyAll() { await Clients.All.Message1(); await Clients.All.Message2(); // Guaranteed order } } ``` ### Blocking Async Code Blocking calls cause thread pool starvation: ```csharp // BAD: Blocking on async public class BadHub : Hub { private readonly IDataService _dataService; public string GetData() { // Blocks thread pool thread return _dataService.GetDataAsync().Result; } public void SendBlocking() { // Potential deadlock Clients.All.SendAsync("Method").Wait(); } } // GOOD: Async all the way public class GoodHub : Hub { private readonly IDataService _dataService; public async Task<string> GetData() { return await _dataService.GetDataAsync(); } public async Task Send() { await Clients.All.SendAsync("Method"); } } ``` ## Connection Management Anti-Patterns ### Ignoring Group Loss on Reconnection Group membership is tied to connection ID, which changes on reconnect: ```csharp // BAD: Assumes groups persist across reconnection public class BadHub : Hub { public async Task JoinRoom(string roomId) { await Groups.AddToGroupAsync(Context.ConnectionId, roomId); // Client reconnects with new connection ID = no longer in group } } // GOOD: Track groups and rejoin on connect public class GoodHub : Hub { private readonly IGroupMembershipStore _store; public GoodHub(IGroupMembershipStore store) => _store = store; public override async Task OnConnectedAsync() { var userId = Context.User!.GetUserId(); var groups = await _store.GetUserGroupsAsync(userId); foreach (var group in groups) { await Groups.AddToGroupAsync(Context.ConnectionId, group); } await base.OnConnectedAsync(); } public async Task JoinRoom(string roomId) { var userId = Context.User!.GetUserId(); // Persist membership await _store.AddUserToGroupAsync(userId, roomId); // Add current connection await Groups.AddToGroupAsync(Context.ConnectionId, roomId); } } ``` Client-side handling is also required: ```javascript // BAD: No reconnection handling connection.start(); // GOOD: Rejoin groups after reconnection connection.onreconnected(async (connectionId) => { console.log("Reconnected with ID:", connectionId); // Rejoin all rooms for (const roomId of joinedRooms) { await connection.invoke("JoinRoom", roomId); } }); ``` ### Not Handling Connection Lifecycle Ignoring connect/disconnect events loses cleanup opportunities: ```csharp // BAD: No lifecycle handling public class BadHub : Hub { public Task JoinGame(string gameId) { // What happens when they disconnect mid-game? return Groups.AddToGroupAsync(Context.ConnectionId, gameId); } } // GOOD: Handle lifecycle events public class GoodHub : Hub { private readonly IGameService _gameService; public GoodHub(IGameService gameService) => _gameService = gameService; public async Task JoinGame(string gameId) { Context.Items["GameId"] = gameId; await _gameService.AddPlayerAsync(gameId, Context.User!.GetUserId()); await Groups.AddToGroupAsync(Context.ConnectionId, gameId); } public override async Task OnDisconnectedAsync(Exception? exception) { if (Context.Items.TryGetValue("GameId", out var gameIdObj) && gameIdObj is string gameId) { await _gameService.RemovePlayerAsync(gameId, Context.User!.GetUserId()); await Clients.Group(gameId).PlayerLeft(Context.User!.GetUserId()); } await base.OnDisconnectedAsync(exception); } } ``` ## Security Anti-Patterns ### Not Validating Hub Method Inputs Authentication does not equal authorization or validation: ```csharp // BAD: No validation after auth [Authorize] public class BadHub : Hub { public async Task SendToGroup(string groupId, string message) { // Is user allowed in this group? Unknown! // Is message content safe? Unknown! await Clients.Group(groupId).ReceiveMessage(message); } public async Task GetUserData(string userId) { // Can caller access this user's data? Not checked! var data = await _userService.GetDataAsync(userId); await Clients.Caller.ReceiveData(data); } } // GOOD: Validate every hub method [Authorize] public class GoodHub : Hub { private readonly IAuthorizationService _authService; private readonly IValidator<SendMessageRequest> _validator; public async Task SendToGroup(string groupId, SendMessageRequest request) { // Check authorization var canSend = await _authService.CanSendToGroupAsync(Context.User!, groupId); if (!canSend) { throw new HubException("Not authorized for this group"); } // Validate content var validation = await _validator.ValidateAsync(request); if (!validation.IsValid) { throw new HubException($"Invalid message: {validation.Errors.First().ErrorMessage}"); } await Clients.Group(groupId).ReceiveMessage(request.Message); } public async Task GetUserData(string userId) { // Verify caller can access this data var callerId = Context.User!.GetUserId(); if (userId != callerId && !Context.User.IsInRole("Admin")) { throw new HubException("Not authorized to access this user's data"); } var data = await _userService.GetDataAsync(userId); await Clients.Caller.ReceiveData(data); } } ``` ### Exposing Internal Exceptions Internal errors leak implementation details: ```csharp // BAD: Exceptions leak to client public class BadHub : Hub { public async Task ProcessOrder(OrderRequest request) { // SqlException details visible to client // Stack traces visible to client await _orderService.ProcessAsync(request); } } // GOOD: Wrap exceptions public class GoodHub : Hub { private readonly ILogger<GoodHub> _logger; public async Task ProcessOrder(OrderRequest request) { try { await _orderService.ProcessAsync(request); } catch (ValidationException ex) { throw new HubException($"Validation error: {ex.Message}"); } catch (BusinessRuleException ex) { throw new HubException(ex.UserFriendlyMessage); } catch (Exception ex) { _logger.LogError(ex, "Error processing order"); throw new HubException("An error occurred processing your order"); } } } ``` ## Scaling Anti-Patterns ### Missing Backplane for Multi-Server Without a backplane, messages only reach local connections: ```csharp // BAD: Single-server assumption builder.Services.AddSignalR(); // Users on Server B never receive messages from Server A // GOOD: Use backplane for multi-server builder.Services.AddSignalR() .AddStackExchangeRedis(connectionString, options => { options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp"); }); // OR: Use Azure SignalR Service builder.Services.AddSignalR() .AddAzureSignalR(connectionString); ``` ### Storing Connection State In-Memory for Scale-Out In-memory state does not scale: ```csharp // BAD: In-memory state with scale-out public class BadPresenceTracker { // Only tracks connections on this server private readonly ConcurrentDictionary<string, string> _connections = new(); } // GOOD: Distributed state public class GoodPresenceTracker { private readonly IDistributedCache _cache; // OR private readonly IConnectionMultiplexer _redis; // OR private readonly IDatabase _database; } ``` ## Performance Anti-Patterns ### Large Payloads Over SignalR SignalR is optimized for small, frequent messages: ```csharp // BAD: Large file transfer over SignalR public class BadHub : Hub { public async Task UploadFile(byte[] fileData) // 50MB payload { await _fileService.SaveAsync(fileData); } public async Task<byte[]> DownloadFile(string fileId) { return await _fileService.GetAsync(fileId); // Returns 50MB } } // GOOD: Use SignalR for signaling, HTTP for bulk data public class GoodHub : Hub { public async Task<string> RequestUploadUrl() { // Return pre-signed URL for direct upload return await _blobService.GetUploadUrlAsync(); } public async Task NotifyUploadComplete(string fileId) { await Clients.Others.FileUploaded(fileId); } public async Task<string> RequestDownloadUrl(string fileId) { return await _blobService.GetDownloadUrlAsync(fileId); } } ``` ### No Throttling for High-Frequency Events Unthrottled events overwhelm clients and servers: ```csharp // BAD: Every keystroke sends a message public class BadHub : Hub { public async Task UserTyping() { await Clients.Others.UserIsTyping(Context.User!.Identity!.Name!); } } // GOOD: Throttle high-frequency events public class GoodHub : Hub { private readonly IThrottler _throttler; public async Task UserTyping() { var key = $"typing:{Context.ConnectionId}"; if (await _throttler.ShouldThrottleAsync(key, TimeSpan.FromSeconds(2))) { return; // Skip if sent within last 2 seconds } await Clients.Others.UserIsTyping(Context.User!.Identity!.Name!); } } ``` ### Not Using Streaming for Large Result Sets Loading everything into memory wastes resources: ```csharp // BAD: Load all records into memory public class BadHub : Hub { public async Task<List<LogEntry>> GetLogs(DateTime from, DateTime to) { // Loads potentially millions of records return await _logService.GetAllLogsAsync(from, to); } } // GOOD: Stream results public class GoodHub : Hub { public async IAsyncEnumerable<LogEntry> StreamLogs( DateTime from, DateTime to, [EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var log in _logService.StreamLogsAsync(from, to, cancellationToken)) { yield return log; } } } ``` ## API Design Anti-Patterns ### Using Dynamic Hubs Dynamic invocations lose compile-time safety: ```csharp // BAD: Dynamic client invocation public class BadHub : Hub { public async Task SendMessage(string message) { // Typos not caught at compile time await Clients.All.SendAsync("RecieveMessage", message); // Typo! } } // GOOD: Strongly-typed hub public interface IChatClient { Task ReceiveMessage(string message); } public class GoodHub : Hub<IChatClient> { public async Task SendMessage(string message) { // Compile-time checking await Clients.All.ReceiveMessage(message); } } ``` ### Breaking API Changes Changing method signatures breaks existing clients: ```csharp // BAD: Breaking change public class HubV1 : Hub { // Original public Task SendMessage(string message) => ...; // Changed to add parameter - breaks existing clients! public Task SendMessage(string message, string category) => ...; } // GOOD: Use request objects for evolution public class SendMessageRequest { public string Message { get; set; } = ""; public string? Category { get; set; } // Added later, optional public int? Priority { get; set; } // Added later, optional } public class GoodHub : Hub<IChatClient> { public async Task SendMessage(SendMessageRequest request) { // Handles both old and new clients var category = request.Category ?? "general"; await ProcessMessageAsync(request.Message, category); } } ``` ### Exposing ORM Entities Direct entity exposure leaks implementation and sensitive data: ```csharp // BAD: Exposing EF entities public class BadHub : Hub { public async Task<User> GetUser(string userId) { // May include navigation properties, password hashes, etc. return await _dbContext.Users.FindAsync(userId); } } // GOOD: Use DTOs public class GoodHub : Hub { public async Task<UserDto> GetUser(string userId) { var user = await _dbContext.Users.FindAsync(userId); return new UserDto { Id = user.Id, DisplayName = user.DisplayName, AvatarUrl = user.AvatarUrl // No password hash, no internal fields }; } } ``` ## Diagnostic Anti-Patterns ### No Logging for Connection Events Silent connections make debugging impossible: ```csharp // BAD: No visibility public class BadHub : Hub { // No logging, no metrics } // GOOD: Log important events public class GoodHub : Hub { private readonly ILogger<GoodHub> _logger; public GoodHub(ILogger<GoodHub> logger) => _logger = logger; public override async Task OnConnectedAsync() { _logger.LogInformation( "Client connected: {ConnectionId}, User: {User}, Transport: {Transport}", Context.ConnectionId, Context.User?.Identity?.Name ?? "anonymous", Context.Features.Get<IHttpTransportFeature>()?.TransportType); await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { if (exception != null) { _logger.LogWarning(exception, "Client disconnected with error: {ConnectionId}", Context.ConnectionId); } else { _logger.LogInformation( "Client disconnected: {ConnectionId}", Context.ConnectionId); } await base.OnDisconnectedAsync(exception); } } ``` ### Not Monitoring Message Delivery Assuming messages always arrive: ```csharp // BAD: Fire and forget without tracking public class BadHub : Hub { public async Task Broadcast(string message) { await Clients.All.ReceiveMessage(message); // Did everyone get it? Unknown! } } // GOOD: Track delivery metrics public class GoodHub : Hub { private readonly IMetrics _metrics; private readonly ILogger<GoodHub> _logger; public async Task Broadcast(string message) { var stopwatch = Stopwatch.StartNew(); try { await Clients.All.ReceiveMessage(message); _metrics.RecordBroadcast(stopwatch.ElapsedMilliseconds); } catch (Exception ex) { _metrics.RecordBroadcastFailure(); _logger.LogError(ex, "Broadcast failed"); throw; } } } ``` ## Testing Anti-Patterns ### Not Testing Reconnection Scenarios Only testing happy paths misses critical failures: ```csharp // BAD: Only test message sending [Fact] public async Task CanSendMessage() { await _connection.InvokeAsync("SendMessage", "hello"); // What about reconnection? Disconnection? } // GOOD: Test connection lifecycle [Fact] public async Task ReconnectionRestoresGroupMembership() { await _connection.InvokeAsync("JoinRoom", "test-room"); // Simulate network failure await _connection.StopAsync(); await _connection.StartAsync(); // Verify group membership restored var isInRoom = await _connection.InvokeAsync<bool>("IsInRoom", "test-room"); Assert.True(isInRoom); } [Fact] public async Task DisconnectionCleansUpResources() { await _connection.InvokeAsync("JoinGame", "game-1"); await _connection.StopAsync(); // Verify cleanup occurred var game = await _gameService.GetGameAsync("game-1"); Assert.DoesNotContain(_userId, game.Players); } ``` ### Not Testing Under Load Performance issues only appear at scale: ```csharp // GOOD: Load test SignalR [Fact] public async Task HandlesHighMessageVolume() { var connections = new List<HubConnection>(); var receivedCounts = new ConcurrentDictionary<string, int>(); // Create many connections for (int i = 0; i < 100; i++) { var connection = CreateConnection(); var connId = $"conn-{i}"; receivedCounts[connId] = 0; connection.On<string>("ReceiveMessage", msg => { receivedCounts.AddOrUpdate(connId, 1, (_, c) => c + 1); }); await connection.StartAsync(); connections.Add(connection); } // Send many messages var sendTasks = Enumerable.Range(0, 1000) .Select(i => connections[i % connections.Count].InvokeAsync("SendMessage", $"msg-{i}")); await Task.WhenAll(sendTasks); await Task.Delay(5000); // Allow delivery // Verify delivery foreach (var count in receivedCounts.Values) { Assert.Equal(1000, count); } } ``` -
patterns.md 20.1 KB
# SignalR Hub Patterns This reference provides detailed patterns for implementing SignalR hubs, streaming, groups, and connection management. ## Hub Design Patterns ### Hub per Feature Separate hubs by domain feature rather than creating one monolithic hub: ```csharp // Good: Feature-focused hubs app.MapHub<ChatHub>("/hubs/chat"); app.MapHub<NotificationHub>("/hubs/notifications"); app.MapHub<CollaborationHub>("/hubs/collaboration"); app.MapHub<PresenceHub>("/hubs/presence"); // Bad: One hub for everything app.MapHub<ApplicationHub>("/hubs/app"); // Too broad ``` ### Hub Method Delegation Pattern Keep hub methods thin and delegate to services: ```csharp public class OrderHub : Hub<IOrderClient> { private readonly IOrderService _orderService; private readonly IValidator<PlaceOrderRequest> _validator; private readonly ILogger<OrderHub> _logger; public OrderHub( IOrderService orderService, IValidator<PlaceOrderRequest> validator, ILogger<OrderHub> logger) { _orderService = orderService; _validator = validator; _logger = logger; } public async Task<OrderResult> PlaceOrder(PlaceOrderRequest request) { // 1. Validate var validation = await _validator.ValidateAsync(request); if (!validation.IsValid) { return OrderResult.Failed(validation.Errors); } // 2. Delegate to service var order = await _orderService.PlaceOrderAsync( request, Context.User!.GetUserId()); // 3. Broadcast (orchestration only) await Clients.Group($"order-watchers-{order.CustomerId}") .OrderPlaced(order.ToDto()); return OrderResult.Success(order.Id); } } ``` ### Connection Context Pattern Use connection items for per-connection state: ```csharp public class GameHub : Hub<IGameClient> { public async Task JoinGame(string gameId) { // Store per-connection state Context.Items["GameId"] = gameId; Context.Items["JoinedAt"] = DateTime.UtcNow; await Groups.AddToGroupAsync(Context.ConnectionId, $"game-{gameId}"); await Clients.Group($"game-{gameId}").PlayerJoined(Context.User!.Identity!.Name!); } public async Task MakeMove(MoveRequest move) { // Retrieve per-connection state var gameId = Context.Items["GameId"] as string ?? throw new HubException("Not in a game"); // Process move... } public override async Task OnDisconnectedAsync(Exception? exception) { if (Context.Items.TryGetValue("GameId", out var gameIdObj) && gameIdObj is string gameId) { await Clients.Group($"game-{gameId}").PlayerLeft(Context.User!.Identity!.Name!); } await base.OnDisconnectedAsync(exception); } } ``` ## Streaming Patterns ### Server-to-Client Streaming Use `IAsyncEnumerable<T>` for server-to-client streams: ```csharp public class DataHub : Hub<IDataClient> { private readonly IDataService _dataService; public DataHub(IDataService dataService) => _dataService = dataService; // Client calls: connection.stream("StreamData", query) public async IAsyncEnumerable<DataItem> StreamData( DataQuery query, [EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var item in _dataService.GetDataStreamAsync(query, cancellationToken)) { // Check cancellation between yields cancellationToken.ThrowIfCancellationRequested(); yield return item; } } // Alternative with ChannelReader for more control public ChannelReader<StockQuote> StreamStockQuotes( string[] symbols, CancellationToken cancellationToken) { var channel = Channel.CreateUnbounded<StockQuote>(); _ = WriteQuotesToChannelAsync(channel.Writer, symbols, cancellationToken); return channel.Reader; } private async Task WriteQuotesToChannelAsync( ChannelWriter<StockQuote> writer, string[] symbols, CancellationToken cancellationToken) { try { while (!cancellationToken.IsCancellationRequested) { foreach (var symbol in symbols) { var quote = await GetQuoteAsync(symbol); await writer.WriteAsync(quote, cancellationToken); } await Task.Delay(1000, cancellationToken); } } catch (OperationCanceledException) { // Expected when client disconnects } finally { writer.Complete(); } } } ``` ### Client-to-Server Streaming Accept `ChannelReader<T>` or `IAsyncEnumerable<T>` for client uploads: ```csharp public class UploadHub : Hub<IUploadClient> { public async Task UploadChunks( string fileName, ChannelReader<byte[]> stream) { var totalBytes = 0L; await foreach (var chunk in stream.ReadAllAsync(Context.ConnectionAborted)) { // Process each chunk await ProcessChunkAsync(fileName, chunk); totalBytes += chunk.Length; // Report progress back to client await Clients.Caller.UploadProgress(totalBytes); } await Clients.Caller.UploadComplete(fileName, totalBytes); } // IAsyncEnumerable variant public async Task StreamMessages(IAsyncEnumerable<ChatMessage> stream) { await foreach (var message in stream) { // Validate each message if (string.IsNullOrWhiteSpace(message.Content)) continue; // Broadcast to group await Clients.Group(message.RoomId).ReceiveMessage(message); } } } ``` ### Bidirectional Streaming Combine both patterns for full-duplex streams: ```csharp public class CollaborationHub : Hub<ICollaborationClient> { public async IAsyncEnumerable<DocumentChange> Collaborate( string documentId, IAsyncEnumerable<DocumentEdit> edits, [EnumeratorCancellation] CancellationToken cancellationToken) { // Join the document group to receive others' changes await Groups.AddToGroupAsync(Context.ConnectionId, $"doc-{documentId}"); // Start a background task to process incoming edits var editProcessor = ProcessEditsAsync(documentId, edits, cancellationToken); // Stream out all changes from other users var changeChannel = GetChangeChannel(documentId); await foreach (var change in changeChannel.ReadAllAsync(cancellationToken)) { // Skip changes from this connection if (change.ConnectionId != Context.ConnectionId) { yield return change; } } await editProcessor; } } ``` ## Group Patterns ### Hierarchical Groups Model group membership hierarchically: ```csharp public class OrganizationHub : Hub<IOrgClient> { public async Task JoinOrganization(string orgId) { // Hierarchical group structure await Groups.AddToGroupAsync(Context.ConnectionId, $"org-{orgId}"); } public async Task JoinTeam(string orgId, string teamId) { // More specific group await Groups.AddToGroupAsync(Context.ConnectionId, $"org-{orgId}-team-{teamId}"); } public async Task JoinProject(string orgId, string teamId, string projectId) { // Most specific group await Groups.AddToGroupAsync(Context.ConnectionId, $"org-{orgId}-team-{teamId}-project-{projectId}"); } public async Task NotifyOrganization(string orgId, Notification notification) { // Reaches everyone in the org await Clients.Group($"org-{orgId}").ReceiveNotification(notification); } public async Task NotifyTeam(string orgId, string teamId, Notification notification) { // Reaches only team members await Clients.Group($"org-{orgId}-team-{teamId}").ReceiveNotification(notification); } } ``` ### Group Membership Tracking Track group membership for presence features: ```csharp public class PresenceHub : Hub<IPresenceClient> { private readonly IGroupMembershipService _membership; public PresenceHub(IGroupMembershipService membership) => _membership = membership; public async Task JoinRoom(string roomId) { var userId = Context.User!.GetUserId(); var connectionId = Context.ConnectionId; // Track in persistent store await _membership.AddMemberAsync(roomId, userId, connectionId); // Add to SignalR group await Groups.AddToGroupAsync(connectionId, roomId); // Get current members and notify var members = await _membership.GetMembersAsync(roomId); await Clients.Caller.RoomMembers(members); await Clients.OthersInGroup(roomId).UserJoined(userId); } public override async Task OnDisconnectedAsync(Exception? exception) { var userId = Context.User!.GetUserId(); var connectionId = Context.ConnectionId; // Find and clean up all rooms this connection was in var rooms = await _membership.GetRoomsForConnectionAsync(connectionId); foreach (var roomId in rooms) { await _membership.RemoveMemberAsync(roomId, connectionId); // Check if user has other connections in this room var hasOtherConnections = await _membership.UserHasConnectionsInRoomAsync(roomId, userId); if (!hasOtherConnections) { await Clients.Group(roomId).UserLeft(userId); } } await base.OnDisconnectedAsync(exception); } } ``` ### Excluding Connections from Group Sends Use exclusion lists for targeted broadcasts: ```csharp public class BroadcastHub : Hub<IBroadcastClient> { public async Task SendToGroupExcept( string groupName, string message, string[] excludeConnectionIds) { await Clients .GroupExcept(groupName, excludeConnectionIds) .ReceiveMessage(message); } public async Task SendToAllExceptCaller(string message) { // Built-in exclusion of caller await Clients.Others.ReceiveMessage(message); } public async Task SendToGroupExceptCaller(string groupName, string message) { // Exclude caller from group send await Clients.OthersInGroup(groupName).ReceiveMessage(message); } } ``` ## User-Based Targeting ### Multi-Connection Users Handle users with multiple connections: ```csharp public class UserHub : Hub<IUserClient> { public async Task SendToUser(string userId, Message message) { // Reaches all connections for this user await Clients.User(userId).ReceiveMessage(message); } public async Task SendToUsers(string[] userIds, Message message) { await Clients.Users(userIds).ReceiveMessage(message); } } ``` ### Custom User ID Provider Map connections to custom user identifiers: ```csharp public class TenantUserIdProvider : IUserIdProvider { public string? GetUserId(HubConnectionContext connection) { var tenantId = connection.User?.FindFirst("tenant_id")?.Value; var userId = connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (tenantId != null && userId != null) { // Namespace user IDs by tenant for multi-tenant apps return $"{tenantId}:{userId}"; } return userId; } } // Registration builder.Services.AddSingleton<IUserIdProvider, TenantUserIdProvider>(); ``` ## Presence Patterns ### Connection Counting Track user presence across connections: ```csharp public class PresenceTracker { private readonly ConcurrentDictionary<string, HashSet<string>> _userConnections = new(); public bool AddConnection(string userId, string connectionId) { var connections = _userConnections.GetOrAdd(userId, _ => new HashSet<string>()); lock (connections) { var wasEmpty = connections.Count == 0; connections.Add(connectionId); return wasEmpty; // True if this is the first connection } } public bool RemoveConnection(string userId, string connectionId) { if (_userConnections.TryGetValue(userId, out var connections)) { lock (connections) { connections.Remove(connectionId); if (connections.Count == 0) { _userConnections.TryRemove(userId, out _); return true; // User is now offline } } } return false; } public string[] GetOnlineUsers() => _userConnections.Keys.ToArray(); } ``` ### Presence with Redis (Scale-Out) Distributed presence for multi-server deployments: ```csharp public class RedisPresenceService : IPresenceService { private readonly IConnectionMultiplexer _redis; private readonly string _serverInstance; public RedisPresenceService(IConnectionMultiplexer redis) { _redis = redis; _serverInstance = $"{Environment.MachineName}-{Process.GetCurrentProcess().Id}"; } public async Task<bool> UserConnectedAsync(string userId, string connectionId) { var db = _redis.GetDatabase(); var key = $"presence:{userId}"; var wasEmpty = !(await db.KeyExistsAsync(key)); await db.HashSetAsync(key, connectionId, _serverInstance); await db.KeyExpireAsync(key, TimeSpan.FromMinutes(30)); return wasEmpty; } public async Task<bool> UserDisconnectedAsync(string userId, string connectionId) { var db = _redis.GetDatabase(); var key = $"presence:{userId}"; await db.HashDeleteAsync(key, connectionId); var remaining = await db.HashLengthAsync(key); return remaining == 0; } public async Task<string[]> GetOnlineUsersAsync() { var server = _redis.GetServer(_redis.GetEndPoints().First()); var keys = server.Keys(pattern: "presence:*"); return keys.Select(k => k.ToString().Replace("presence:", "")).ToArray(); } } ``` ## Request-Response Pattern ### Hub Methods with Return Values Return results directly from hub methods: ```csharp public class QueryHub : Hub<IQueryClient> { private readonly IQueryService _queryService; public QueryHub(IQueryService queryService) => _queryService = queryService; public async Task<SearchResult> Search(SearchRequest request) { // Validate if (string.IsNullOrEmpty(request.Query)) { throw new HubException("Query is required"); } // Execute and return return await _queryService.SearchAsync(request, Context.ConnectionAborted); } public async Task<PagedResult<Item>> GetItems(int page, int pageSize) { if (pageSize > 100) { throw new HubException("Page size cannot exceed 100"); } return await _queryService.GetItemsAsync(page, pageSize); } } ``` ### Error Handling Use `HubException` for client-visible errors: ```csharp public class RobustHub : Hub<IRobustClient> { public async Task<OperationResult> PerformOperation(OperationRequest request) { try { // Business logic... return OperationResult.Success(); } catch (ValidationException ex) { // Client sees this message throw new HubException($"Validation failed: {ex.Message}"); } catch (NotFoundException ex) { throw new HubException($"Not found: {ex.Message}"); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error in PerformOperation"); // Generic message for unexpected errors throw new HubException("An unexpected error occurred"); } } } ``` ## Acknowledgment Pattern ### Message Acknowledgments Track message delivery acknowledgments: ```csharp public interface IReliableClient { Task ReceiveMessage(string messageId, Message message); Task MessageAcknowledged(string messageId); } public class ReliableHub : Hub<IReliableClient> { private readonly IPendingMessageStore _pendingStore; public ReliableHub(IPendingMessageStore pendingStore) => _pendingStore = pendingStore; public async Task SendReliable(string recipientId, Message message) { var messageId = Guid.NewGuid().ToString(); // Store pending message await _pendingStore.StorePendingAsync(messageId, recipientId, message); // Attempt delivery await Clients.User(recipientId).ReceiveMessage(messageId, message); // Notify sender await Clients.Caller.MessageAcknowledged(messageId); } public async Task AcknowledgeMessage(string messageId) { // Mark as delivered await _pendingStore.MarkDeliveredAsync(messageId); } // Background job retries unacknowledged messages } ``` ## Batching Pattern ### Message Batching for High Throughput Batch messages to reduce overhead: ```csharp public class BatchingHub : Hub<IBatchingClient> { private readonly IBatchService _batchService; public BatchingHub(IBatchService batchService) => _batchService = batchService; public async Task StartBatchedUpdates(string subscriptionId) { var channel = Channel.CreateBounded<Update>(new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.DropOldest }); // Register this connection for updates _batchService.RegisterSubscription(subscriptionId, Context.ConnectionId, channel.Writer); // Background batching loop _ = Task.Run(async () => { var batch = new List<Update>(); var batchTimeout = TimeSpan.FromMilliseconds(100); while (!Context.ConnectionAborted.IsCancellationRequested) { batch.Clear(); // Collect items for up to 100ms or 50 items using var cts = CancellationTokenSource.CreateLinkedTokenSource(Context.ConnectionAborted); cts.CancelAfter(batchTimeout); try { while (batch.Count < 50 && await channel.Reader.WaitToReadAsync(cts.Token)) { if (channel.Reader.TryRead(out var item)) { batch.Add(item); } } } catch (OperationCanceledException) { // Timeout or disconnect - send what we have } if (batch.Count > 0) { await Clients.Caller.ReceiveBatch(batch.ToArray()); } } }); } } ``` ## Throttling Pattern ### Rate Limiting Hub Methods Implement per-connection rate limiting: ```csharp public class ThrottledHub : Hub<IThrottledClient> { private readonly IRateLimiter _rateLimiter; public ThrottledHub(IRateLimiter rateLimiter) => _rateLimiter = rateLimiter; public async Task SendMessage(string message) { var key = $"hub:{Context.ConnectionId}:sendMessage"; if (!await _rateLimiter.TryAcquireAsync(key, maxRequests: 10, window: TimeSpan.FromSeconds(1))) { throw new HubException("Rate limit exceeded. Please slow down."); } await Clients.All.ReceiveMessage(Context.User!.Identity!.Name!, message); } } // Using System.Threading.RateLimiting public class SlidingWindowRateLimiter : IRateLimiter { private readonly ConcurrentDictionary<string, RateLimiter> _limiters = new(); public async ValueTask<bool> TryAcquireAsync(string key, int maxRequests, TimeSpan window) { var limiter = _limiters.GetOrAdd(key, _ => new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions { PermitLimit = maxRequests, Window = window, SegmentsPerWindow = 4, AutoReplenishment = true })); using var lease = await limiter.AcquireAsync(); return lease.IsAcquired; } } ```
-
-
SKILL.md 11.7 KB
--- name: dotnet-signalr version: "1.0.0" category: "Web" description: "Implement or review SignalR hubs, streaming, reconnection, transport, and real-time delivery patterns in ASP.NET Core applications." compatibility: "Requires ASP.NET Core SignalR server or client code." --- # SignalR ## Trigger On - building chat, notification, collaboration, or live-update features - debugging hub lifetime, connection state, or transport issues - deciding whether SignalR or another transport better fits the scenario - implementing real-time broadcasting to groups of connected clients - scaling SignalR across multiple servers ## Documentation - [ASP.NET Core SignalR Overview](https://learn.microsoft.com/en-us/aspnet/core/signalr/introduction?view=aspnetcore-10.0) - [SignalR Hubs](https://learn.microsoft.com/en-us/aspnet/core/signalr/hubs?view=aspnetcore-10.0) - [SignalR API Design Considerations](https://learn.microsoft.com/en-us/aspnet/core/signalr/api-design?view=aspnetcore-10.0) - [SignalR Production Hosting and Scaling](https://learn.microsoft.com/en-us/aspnet/core/signalr/scale?view=aspnetcore-10.0) - [SignalR Configuration](https://learn.microsoft.com/en-us/aspnet/core/signalr/configuration?view=aspnetcore-10.0) ### References - [patterns.md](references/patterns.md) - Detailed hub patterns, streaming, groups, presence, and advanced messaging techniques - [anti-patterns.md](references/anti-patterns.md) - Common SignalR mistakes and how to avoid them ## Workflow 1. Use SignalR for broadcast-style or connection-oriented real-time features; do not force gRPC into hub-style fan-out scenarios. 2. Model hub contracts intentionally and keep hub methods thin, delegating durable work elsewhere. 3. Plan for reconnection, backpressure, auth, and fan-out costs instead of treating real-time messaging as stateless request/response. 4. Use groups, presence, and connection metadata deliberately so scale-out behavior is understandable. 5. If Native AOT or trimming is in play, validate supported protocols and serialization choices explicitly. 6. Test connection behavior and failure modes, not just happy-path message delivery. ## Hub Patterns ### Strongly-Typed Hub (Recommended) ```csharp // Define the client interface public interface IChatClient { Task ReceiveMessage(string user, string message); Task UserJoined(string user); Task UserLeft(string user); } // Implement the strongly-typed hub public class ChatHub : Hub<IChatClient> { public async Task SendMessage(string user, string message) { // Compiler checks client method calls await Clients.All.ReceiveMessage(user, message); } public override async Task OnConnectedAsync() { await Clients.Others.UserJoined(Context.User?.Identity?.Name ?? "Anonymous"); await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { await Clients.Others.UserLeft(Context.User?.Identity?.Name ?? "Anonymous"); await base.OnDisconnectedAsync(exception); } } ``` ### Using Groups for Targeted Messaging ```csharp public class NotificationHub : Hub<INotificationClient> { public async Task JoinGroup(string groupName) { await Groups.AddToGroupAsync(Context.ConnectionId, groupName); await Clients.Group(groupName).UserJoined(Context.User?.Identity?.Name); } public async Task LeaveGroup(string groupName) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); } public async Task SendToGroup(string groupName, string message) { await Clients.Group(groupName).ReceiveNotification(message); } } ``` ### Hub Method with Custom Object Parameters (API Versioning) ```csharp // Use custom objects to avoid breaking changes public class SendMessageRequest { public string Message { get; set; } = string.Empty; public string? Recipient { get; set; } // Added later without breaking clients public int? Priority { get; set; } // Added later without breaking clients } public class ChatHub : Hub<IChatClient> { public async Task SendMessage(SendMessageRequest request) { // Handle both old and new clients if (request.Recipient != null) { await Clients.User(request.Recipient).ReceiveMessage(request.Message); } else { await Clients.All.ReceiveMessage(request.Message); } } } ``` ## Client Patterns ### JavaScript Client with Automatic Reconnection ```javascript const connection = new signalR.HubConnectionBuilder() .withUrl("/chatHub") .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) // Retry delays .configureLogging(signalR.LogLevel.Information) .build(); // Handle reconnection events connection.onreconnecting(error => { console.log("Reconnecting...", error); updateUIForReconnecting(); }); connection.onreconnected(connectionId => { console.log("Reconnected with ID:", connectionId); // Rejoin groups - reconnection does not restore group membership rejoinGroups(); updateUIForConnected(); }); connection.onclose(error => { console.log("Connection closed", error); updateUIForDisconnected(); }); async function start() { try { await connection.start(); console.log("SignalR Connected"); } catch (err) { console.log(err); setTimeout(start, 5000); } } start(); ``` ### .NET Client with Reconnection ```csharp var connection = new HubConnectionBuilder() .WithUrl("https://localhost:5001/chatHub", options => { options.AccessTokenProvider = () => Task.FromResult(GetAccessToken()); }) .WithAutomaticReconnect() .Build(); connection.Reconnecting += error => { _logger.LogWarning("Connection lost. Reconnecting: {Error}", error?.Message); return Task.CompletedTask; }; connection.Reconnected += connectionId => { _logger.LogInformation("Reconnected with ID: {ConnectionId}", connectionId); // Rejoin groups after reconnection return RejoinGroupsAsync(); }; connection.Closed += async error => { _logger.LogError("Connection closed: {Error}", error?.Message); await Task.Delay(Random.Shared.Next(0, 5) * 1000); await connection.StartAsync(); }; await connection.StartAsync(); ``` ## Server Configuration ### Hub Registration with Authentication ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddSignalR(options => { options.EnableDetailedErrors = builder.Environment.IsDevelopment(); options.MaximumReceiveMessageSize = 64 * 1024; // 64 KB options.StreamBufferCapacity = 10; options.KeepAliveInterval = TimeSpan.FromSeconds(15); options.ClientTimeoutInterval = TimeSpan.FromSeconds(30); }) .AddMessagePackProtocol(); // Binary protocol for performance builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Events = new JwtBearerEvents { OnMessageReceived = context => { // Read token from query string for WebSocket connections 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"); ``` ### Sending Messages from Outside a Hub ```csharp public class NotificationService { private readonly IHubContext<NotificationHub, INotificationClient> _hubContext; public NotificationService(IHubContext<NotificationHub, INotificationClient> hubContext) { _hubContext = hubContext; } public async Task NotifyAllAsync(string message) { await _hubContext.Clients.All.ReceiveNotification(message); } public async Task NotifyUserAsync(string userId, string message) { await _hubContext.Clients.User(userId).ReceiveNotification(message); } public async Task NotifyGroupAsync(string groupName, string message) { await _hubContext.Clients.Group(groupName).ReceiveNotification(message); } } ``` ## Scaling with Redis Backplane ```csharp builder.Services.AddSignalR() .AddStackExchangeRedis(connectionString, options => { options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp"); }); ``` ## Anti-Patterns to Avoid | Anti-Pattern | Why It's Bad | Better Approach | |--------------|--------------|-----------------| | Storing state in Hub properties | Hub instances are created per method call | Use `IMemoryCache`, database, or external store | | Instantiating Hub directly | Bypasses SignalR infrastructure | Use `IHubContext<THub>` for external messaging | | Not awaiting `SendAsync` calls | Messages may not be sent before hub method completes | Always `await` async hub calls | | Adding method parameters without versioning | Breaking change for existing clients | Use custom object parameters | | Ignoring reconnection group loss | Clients lose group membership on reconnect | Re-add to groups in `OnConnectedAsync` or client reconnect handler | | Large payloads over SignalR | Memory pressure, bandwidth issues | Use REST/gRPC for bulk data, SignalR for notifications | | Missing backplane in multi-server | Messages only reach clients on same server | Use Redis backplane or Azure SignalR Service | | Exposing ORM entities directly | May serialize sensitive data | Use DTOs with explicit properties | | Not validating incoming messages | Security risk after initial auth | Validate every hub method input | ## Best Practices ### Connection Management 1. **Enable automatic reconnection** with exponential backoff delays 2. **Handle group rejoining** explicitly after reconnection (connection ID changes) 3. **Implement heartbeat monitoring** on the client to detect stale connections 4. **Use sticky sessions** when scaling across multiple servers (unless using Azure SignalR Service) ### Performance 1. **Use MessagePack protocol** for smaller message sizes and faster serialization 2. **Throttle high-frequency events** like typing indicators or mouse movements 3. **Batch messages** when possible instead of many small sends 4. **Set appropriate buffer sizes** based on expected message throughput ### Security 1. **Authenticate at connection time** using JWT tokens via query string 2. **Authorize hub methods** using `[Authorize]` attribute 3. **Validate all incoming messages** even after authentication 4. **Use HTTPS** for all SignalR connections ### API Design 1. **Use strongly-typed hubs** to catch client method name typos at compile time 2. **Use custom object parameters** to enable backward-compatible API evolution 3. **Version hub names** (e.g., `ChatHubV2`) for breaking changes 4. **Keep hub methods thin** and delegate business logic to services ### Observability 1. **Log connection events** (connect, disconnect, reconnect) 2. **Track transport type** used by each connection 3. **Monitor message delivery** latency and failure rates 4. **Integrate with Application Insights** or other APM tools ## Deliver - clear hub contracts and connection behavior - real-time delivery that matches the product scenario - validation for reconnection and authorization flows - appropriate scale-out strategy for multi-server deployments ## Validate - SignalR is the correct transport for the use case - hub methods remain orchestration-oriented - group and auth behavior are explicit and tested - reconnection and group membership are handled correctly - backplane is configured for multi-server scenarios - message validation is implemented in hub methods
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.