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:
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:
- Groups are created automatically when the first connection is added.
- Groups are removed automatically when the last connection leaves.
- A connection can belong to multiple groups simultaneously.
- Group membership is not preserved across reconnections — the client must rejoin.
Handling Reconnection and Groups
When a client reconnects, its connection ID changes. Any previous group memberships are lost. Handle this in OnConnectedAsync:
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:
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:
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:
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:
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:
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
- Groups are lightweight, server-managed collections of connections.
- Group membership is lost on disconnection — persist it externally if needed.
- Use
Clients.User()to target authenticated users across all their connections. - Combine groups and user targeting for complex scenarios like support chat.
- Track connection-to-user mappings yourself for presence features.