SignalR Groups and Connection Management
Manage SignalR connections and groups: adding/removing from groups, user-based routing, connection tracking, broadcasting to subsets of clients, and patterns for ward-based clinical subscriptions.
Connections, Users, and Groups
Connection: single WebSocket session — one browser tab, one connection ID
ConnectionId = unique string per connection
A user can have multiple connections (multiple tabs)
User: identified by the sub claim in the JWT
Clients.User(userId) targets ALL connections for that user
Group: named set of connections — join/leave dynamically
Clients.Group("ward-4b") targets all connections in that group
Connections can be in multiple groups simultaneouslyAdding and Removing from Groups
public sealed class WardMonitorHub : Hub<IWardMonitorClient>
{
// Client calls this to subscribe to a ward's updates
public async Task SubscribeToWard(string wardId)
{
// Validate user has access to this ward
var userId = Context.User?.FindFirstValue(JwtRegisteredClaimNames.Sub);
if (!await _wardAccess.CanAccessAsync(userId!, wardId))
throw new HubException("Access to this ward is not authorized.");
var groupName = $"ward:{wardId}";
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
// Immediately send current state to the newly subscribed client
var capacity = await _wards.GetCapacityAsync(wardId);
await Clients.Caller.WardStateSnapshot(capacity.ToDto());
}
public async Task UnsubscribeFromWard(string wardId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"ward:{wardId}");
}
// A connection can be in multiple groups simultaneously
public async Task SubscribeToAlerts(string[] alertTypes)
{
foreach (var alertType in alertTypes)
await Groups.AddToGroupAsync(Context.ConnectionId, $"alert:{alertType}");
}
}User-Based Targeting
// Target all connections for a specific user (all their browser tabs/devices)
await _hub.Clients.User(userId).NotifyAsync(notification);
// For user-based targeting to work, map user identifier to connections
// ASP.NET Core SignalR uses IUserIdProvider
public sealed class SubClaimUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection)
=> connection.User?.FindFirstValue(JwtRegisteredClaimNames.Sub);
}
// Register in Program.cs
builder.Services.AddSingleton<IUserIdProvider, SubClaimUserIdProvider>();Connection Tracking
SignalR does not provide a built-in connection registry. Implement one to answer "who is connected?" questions:
// Infrastructure/SignalR/ConnectionTracker.cs
public sealed class ConnectionTracker : IConnectionTracker
{
private readonly ConcurrentDictionary<string, ConnectedUser> _connections = new();
public void Register(string connectionId, string userId, string? wardId)
=> _connections[connectionId] = new ConnectedUser(connectionId, userId, wardId);
public void Unregister(string connectionId)
=> _connections.TryRemove(connectionId, out _);
public int CountByWard(string wardId)
=> _connections.Values.Count(c => c.WardId == wardId);
public IEnumerable<string> GetUserConnectionIds(string userId)
=> _connections.Values
.Where(c => c.UserId == userId)
.Select(c => c.ConnectionId);
public int TotalConnections => _connections.Count;
}
public record ConnectedUser(string ConnectionId, string UserId, string? WardId);
// Hub OnConnected/OnDisconnected
public override Task OnConnectedAsync()
{
var userId = Context.User?.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? "anonymous";
_tracker.Register(Context.ConnectionId, userId, wardId: null);
return base.OnConnectedAsync();
}
public override Task OnDisconnectedAsync(Exception? exception)
{
_tracker.Unregister(Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}Broadcasting Patterns
// From application service — push updates to connected clients
public sealed class PatientAdmissionService
{
private readonly IHubContext<ClinicalDashboardHub, IClinicalDashboardClient> _hub;
public async Task AdmitPatientAsync(AdmitPatientCommand cmd, CancellationToken ct)
{
// ... admit patient in DB
// Notify all ward subscribers
await _hub.Clients
.Group($"ward:{cmd.WardId}")
.PatientAdmitted(new PatientAdmittedDto(
patient.Id, patient.FullName, patient.MRN, cmd.WardId));
// Notify the admitting doctor's other connections (other tabs)
await _hub.Clients
.User(cmd.AdmittingDoctorId.ToString())
.AdmissionConfirmed(patient.Id);
}
}Group Patterns for Clinical Systems
Ward-based subscriptions:
Group name: "ward:{wardId}"
Who joins: nurses and doctors assigned to that ward
Events: patient admitted/discharged, drug orders, bed status
Alert subscriptions:
Group name: "alert:{alertType}" (e.g., "alert:critical", "alert:code-blue")
Who joins: on-call staff, charge nurses
Events: critical alerts, emergency codes
Department-wide events:
Group name: "department:{departmentId}"
Who joins: all staff in the department
Events: shift changes, capacity updates, system messages
Personal notifications:
Use Clients.User(userId) — not a group
Events: task assignments, direct messages, personal alertsClean-Up on Disconnect
public override async Task OnDisconnectedAsync(Exception? exception)
{
// Groups.RemoveFromGroupAsync is NOT needed on disconnect
// SignalR automatically removes the connection from all groups on disconnect
// But do clean up your tracking state
_tracker.Unregister(Context.ConnectionId);
// Optionally notify the ward that a viewer disconnected
var user = _tracker.GetUser(Context.ConnectionId);
if (user?.WardId is not null)
{
await _hub.Clients
.Group($"ward:{user.WardId}")
.ViewerDisconnected(new ViewerDto(user.UserId));
}
await base.OnDisconnectedAsync(exception);
}Production issue I've seen: A team's ward dashboard showed "X clinicians viewing" by counting group members. When the network dropped, SignalR connections timed out but the group count was not updated (no tracking). The dashboard showed 5 active viewers when the ward was empty. Connection tracking with proper
OnDisconnectedAsynccleanup is essential for accurate presence indicators.
Red Flag / Green Answer
Red Flag: "We use Clients.All.SendAsync(...) to broadcast drug order updates — every connected user gets every update."
A pharmacy tech updating a drug order at hospital A is broadcasting to all clinicians at hospitals B, C, and D. With 1,000 concurrent connections, that is 999 unnecessary messages per update. Group-based subscriptions send only to the relevant ward or department.
Green Answer:
Groups per ward/department. Clients join groups when they open the ward view. Updates broadcast to
Clients.Group($"ward:{wardId}")— only the 10-20 connections monitoring that ward receive the message.
Key Takeaway
Groups are named sets of connections that can be targeted for messages. One connection can join multiple groups. User-based targeting (
Clients.User(userId)) reaches all connections for a user. SignalR auto-removes connections from groups on disconnect, but you must manage your own connection tracking for presence indicators. Scope broadcasts to groups — avoidClients.Allunless the event genuinely affects all users.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.