Groups and rooms
Keep server group membership correct across reconnects.
A SignalR group belongs to a connection ID, not to a user. When the connection ID changes, the server forgets the old memberships. Your client must join the groups again.
Why groups are lost
The server removes a closed connection from all groups. A new connection ID appears in these cases:
- SignalR auto-reconnect finishes, unless you turn on stateful reconnect.
- The library rebuilds a lost connection.
baseUrl,enabled, orconnectionKeychanges.- A lazy hub stops and starts again.
In all these cases, the status goes back to "connected". So the status is the reliable signal to join.
The recommended pattern
Join when the status is "connected". Key the effect on the status and the room ID. Do not return a cleanup from this effect, because a status change then leaves and joins again. Leave in a separate cleanup through the injectHubTeardown sender. This sample joins and leaves a room:
import { Component, effect, input } from "@angular/core";
import { injectHubInvoke, injectHubStatus, injectHubTeardown } from "./client";
import { ignoreAbort } from "./ignore-abort";
@Component({ selector: "app-room-members", template: "" })
export class RoomMembersComponent {
roomId = input.required<string>();
private status = injectHubStatus("/hubs/rooms");
private joinRoom = injectHubInvoke("/hubs/rooms", "JoinRoom");
private leaveRoom = injectHubTeardown("/hubs/rooms", "LeaveRoom");
constructor() {
effect(() => {
const roomId = this.roomId();
if (this.status() === "connected") this.joinRoom(roomId).catch(ignoreAbort);
});
effect((onCleanup) => {
const roomId = this.roomId();
onCleanup(() => void this.leaveRoom(roomId));
});
}
}The join call ignores AbortError. An unmount aborts a pending call, and this is not a failure.
The leave is best-effort. If the hub never connected in time, the leave resolves to false and the server removes the connection from its groups when the connection closes.
Make the server join idempotent
The client can join twice, for example, when a retried join runs twice. Adding a connection to a group that it is already in changes nothing, so JoinRoom is safe to repeat:
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;
namespace Server.Hubs;
public record ChatMessage(string RoomId, string User, string Text, DateTimeOffset SentAt);
/// <summary>Teaching hub: chat rooms built on SignalR groups.</summary>
public class RoomHub : Hub
{
private const int HistoryLimit = 50;
private static readonly ConcurrentDictionary<string, Queue<ChatMessage>> History = new();
// Adding a connection that is already in the group changes nothing, so the client can retry.
public Task JoinRoom(string roomId) => Groups.AddToGroupAsync(Context.ConnectionId, roomId);
public Task LeaveRoom(string roomId) => Groups.RemoveFromGroupAsync(Context.ConnectionId, roomId);
public Task SendMessage(string roomId, string text)
{
var message = new ChatMessage(roomId, Context.User?.Identity?.Name ?? "anonymous", text, DateTimeOffset.UtcNow);
var history = History.GetOrAdd(roomId, _ => new Queue<ChatMessage>());
lock (history)
{
history.Enqueue(message);
if (history.Count > HistoryLimit) history.Dequeue();
}
return Clients.Group(roomId).SendAsync("MessageReceived", message);
}
public ChatMessage[] GetHistory(string roomId)
{
if (!History.TryGetValue(roomId, out var history)) return [];
lock (history) return history.ToArray();
}
}Join on the server instead
If group membership follows the identity of the user, not a screen, join in OnConnectedAsync. Every new connection ID, including each reconnect, then joins the group before the client sends anything:
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");
await base.OnConnectedAsync();
}Then the client needs no join call for these groups.
Retries and groups
Set retries above 0 only for idempotent methods. After a drop, a retried call can run twice on the server. JoinRoom is idempotent. SendMessage is not, so do not retry it.