SignalR Groups and User Management

Broadcasting to all connected clients is simple, but most real-time applications need more precision. You want to send order updates only to the customer watching that order, or chat messages only to people in the room. SignalR provides two mechanisms for this: groups and user targeting.

Groups: The Basics

A group is a named collection of connections. Groups are managed entirely by the server — there's no client-side concept of group membership. You add and remove connections using Groups within the hub:

Example.cs
public class ChatHub : Hub<IChatClient>
{
    public async Task JoinRoom(string roomName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
        await Clients.Group(roomName).UserJoined(Context.UserIdentifier!);
    }

    public async Task LeaveRoom(string roomName)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
        await Clients.Group(roomName).UserLeft(Context.UserIdentifier!);
    }

    public async Task SendToRoom(string roomName, string message)
    {
        await Clients.Group(roomName)
            .ReceiveMessage(Context.UserIdentifier!, message);
    }
}

Key facts about groups:

Handling Reconnection and Groups

When a client reconnects, its connection ID changes. Any previous group memberships are lost. Handle this in OnConnectedAsync:

Example.cs
public override async Task OnConnectedAsync()
{
    var userId = Context.UserIdentifier;
    if (userId is not null)
    {
        // Fetch the user's rooms from your data store
        var rooms = await _roomService.GetUserRoomsAsync(userId);

        foreach (var room in rooms)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, room);
        }
    }

    await base.OnConnectedAsync();
}

This pattern requires persisting group membership outside of SignalR — in a database, cache, or in-memory store. SignalR's groups are purely a routing mechanism, not a persistence layer.

User Targeting

If a user is authenticated, SignalR can target them by user ID regardless of how many connections they have. A user with two browser tabs open receives the message on both:

Example.cs
public async Task SendDirectMessage(string targetUserId, string message)
{
    await Clients.User(targetUserId)
        .ReceiveMessage(Context.UserIdentifier!, message);
}

By default, Context.UserIdentifier maps to the ClaimTypes.NameIdentifier claim. You can customise this with IUserIdProvider as covered in the authentication article.

Combining Groups and Users

In practice, you often combine both. Consider a support chat application:

Example.cs
public class SupportHub : Hub<ISupportClient>
{
    private readonly ITicketService _ticketService;

    public SupportHub(ITicketService ticketService)
    {
        _ticketService = ticketService;
    }

    public async Task OpenTicket(string subject)
    {
        var userId = Context.UserIdentifier!;
        var ticket = await _ticketService.CreateAsync(userId, subject);

        // Customer joins their ticket group
        await Groups.AddToGroupAsync(Context.ConnectionId, $"ticket-{ticket.Id}");

        // Notify the support team group
        await Clients.Group("support-agents").NewTicket(ticket);
    }

    public async Task AssignTicket(string ticketId)
    {
        var agentId = Context.UserIdentifier!;
        await _ticketService.AssignAsync(ticketId, agentId);

        // Agent joins the ticket group
        await Groups.AddToGroupAsync(Context.ConnectionId, $"ticket-{ticketId}");

        // Notify everyone in the ticket
        await Clients.Group($"ticket-{ticketId}").AgentAssigned(agentId);
    }

    public async Task SendTicketMessage(string ticketId, string message)
    {
        var senderId = Context.UserIdentifier!;
        await Clients.Group($"ticket-{ticketId}")
            .TicketMessage(ticketId, senderId, message);
    }
}

Sending to Groups from Outside the Hub

Using IHubContext, you can manage groups and send to them from anywhere:

Example.cs
public class OrderService
{
    private readonly IHubContext<OrderHub, IOrderClient> _hubContext;

    public OrderService(IHubContext<OrderHub, IOrderClient> hubContext)
    {
        _hubContext = hubContext;
    }

    public async Task UpdateOrderStatus(string orderId, string status)
    {
        // Send to everyone watching this order
        await _hubContext.Clients.Group($"order-{orderId}")
            .OrderStatusChanged(orderId, status);
    }
}

Note that you cannot add connections to groups from IHubContext — you need the connection ID, and that's only available inside the hub or if you've tracked it yourself.

Excluding Connections

Sometimes you want to send to a group but exclude certain connections:

Example.cs
public async Task SendToRoom(string roomName, string message)
{
    // Send to everyone in the room except the sender
    await Clients.GroupExcept(roomName, new[] { Context.ConnectionId })
        .ReceiveMessage(Context.UserIdentifier!, message);
}

You can also use Clients.OthersInGroup(roomName) as a shorthand to exclude the current caller.

Tracking Connection Mappings

For advanced scenarios — showing who's online in a room, or tracking connection counts — maintain your own mapping:

Example.cs
public class ConnectionTracker
{
    private readonly ConcurrentDictionary<string, HashSet<string>> _userConnections = new();

    public void AddConnection(string userId, string connectionId)
    {
        _userConnections.AddOrUpdate(
            userId,
            _ => new HashSet<string> { connectionId },
            (_, connections) =>
            {
                lock (connections) { connections.Add(connectionId); }
                return connections;
            });
    }

    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 fully disconnected
                }
            }
        }
        return false;
    }

    public IReadOnlyCollection<string> GetOnlineUsers()
    {
        return _userConnections.Keys.ToList().AsReadOnly();
    }
}

Register this as a singleton. Be aware that this only works on a single server — for multi-server deployments, use Redis or a database.

Key Takeaways