Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
SignalRGroupsASP.NET Core.NETReal-Time
Share:𝕏

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 simultaneously

Adding and Removing from Groups

C#
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

C#
// 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:

C#
// 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

C#
// 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 alerts

Clean-Up on Disconnect

C#
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 OnDisconnectedAsync cleanup 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 — avoid Clients.All unless the event genuinely affects all users.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.