
Signalr
- 33 installs
- 466 repo stars
- Updated July 25, 2026
- managedcode/dotnet-skills
Helps with ai & agent building tasks.
About
signalr is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- signalr
- AI & Agent Building
- AI-coding skill
Signalr by the numbers
- 33 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #8,975 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/managedcode/dotnet-skills --skill signalrAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 33 |
|---|---|
| repo stars | ★ 466 |
| Last updated | July 25, 2026 |
| Repository | managedcode/dotnet-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
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
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.
Current Upstream Notes
dotnet/aspnetcorev9.0.17is a servicing release. Keep SignalR architecture guidance focused on hub contract design, reconnection, transport, authorization, and scale-out validation.- After servicing updates, rerun at least one reconnect and group-broadcast smoke path because dependency updates can expose client/server package mismatches.
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
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
{
"version": "1.0.1",
"category": "Web",
"package_prefix": "Microsoft.AspNetCore.SignalR"
}
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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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);
}
}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:
// 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 broadHub Method Delegation Pattern
Keep hub methods thin and delegate to services:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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;
}
}