WebSocket rooms and channel subscriptions #
Socket.IO gives you socket.join('room') and io.to('room').emit(). With the raw ws library you build rooms yourself, and the first version is usually a Map<string, WebSocket[]> that works in the demo. In production, rooms keep references to closed sockets, a broadcast to a 5,000-member room serializes the same payload 5,000 times, leaving a room is an O(n) array scan, and memory grows with every connection that ever existed. A room registry is small, but it sits on the hottest path in a real-time server, so its data structures matter. This page builds one that is correct under churn and cheap to broadcast through.
Root cause #
Rooms are a many-to-many relation between connections and channel names, and most leaks come from indexing only one direction. If you keep room → sockets but not socket → rooms, then when a socket closes you do not know which rooms to remove it from, so you either scan every room (slow) or forget (leak). Arrays compound it: removal needs a scan, duplicates slip in when a client subscribes twice, and a reference held in an array keeps a closed socket — and everything attached to it — alive for the garbage collector.
The broadcast path has its own trap. Calling ws.send(JSON.stringify(payload)) inside the loop repeats the serialization for every member. For a 2 KB message in a 5,000-member room, that is 10 MB of string building per broadcast, on the main thread, for identical output.
Resolution #
Keep two indexes — room → Set<socket> and socket → Set<room> — and update them together. Sets give O(1) join, leave and duplicate protection. Hook the socket’s close event once, at connection time, to remove it from every room it is in. Broadcast by serializing once and sending the same string (or Buffer) to every member, skipping sockets that are not open or are too far behind.
import { WebSocket } from 'ws';
const MAX_ROOMS_PER_SOCKET = 100; // bound per-connection fan-in
const SKIP_IF_BUFFERED_BYTES = 1_000_000; // don't pile more onto a backlogged client
export class RoomRegistry {
private members = new Map<string, Set<WebSocket>>(); // room -> sockets
private roomsOf = new WeakMap<WebSocket, Set<string>>(); // socket -> rooms (no leak if forgotten)
track(ws: WebSocket) {
this.roomsOf.set(ws, new Set());
ws.once('close', () => this.leaveAll(ws)); // the one place cleanup is guaranteed
}
join(ws: WebSocket, room: string): boolean {
const rooms = this.roomsOf.get(ws);
if (!rooms || rooms.has(room)) return false; // untracked or already joined
if (rooms.size >= MAX_ROOMS_PER_SOCKET) throw new Error('room_limit');
rooms.add(room);
let set = this.members.get(room);
if (!set) this.members.set(room, (set = new Set()));
set.add(ws);
return true;
}
leave(ws: WebSocket, room: string) {
this.roomsOf.get(ws)?.delete(room);
const set = this.members.get(room);
if (!set) return;
set.delete(ws);
if (set.size === 0) this.members.delete(room); // empty rooms must not accumulate
}
leaveAll(ws: WebSocket) {
for (const room of this.roomsOf.get(ws) ?? []) this.leave(ws, room);
this.roomsOf.delete(ws);
}
// Serialize once, send many. Returns delivery stats for metrics.
broadcast(room: string, payload: unknown, except?: WebSocket) {
const set = this.members.get(room);
if (!set) return { sent: 0, skipped: 0 };
const frame = JSON.stringify(payload);
let sent = 0, skipped = 0;
for (const ws of set) {
if (ws === except) continue;
if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > SKIP_IF_BUFFERED_BYTES) {
skipped += 1;
continue;
}
ws.send(frame);
sent += 1;
}
return { sent, skipped };
}
size(room: string) { return this.members.get(room)?.size ?? 0; }
roomCount() { return this.members.size; }
}
The WeakMap for the reverse index is a safety net: if some code path creates a socket without ever calling track, it simply has no entry, and nothing retains it. The primary guarantee is still the close hook, which removes the socket from every room in O(rooms-per-socket). Deleting empty rooms matters just as much — a chat product with millions of short-lived direct-message rooms will otherwise accumulate millions of empty Sets.
For binary protocols, serialize to a Buffer once and pass the same Buffer to every send. ws can also pre-frame a message with WebSocket.Sender.frame so the frame header is computed once, which is worth it only for very large rooms. The registry is per node; on a multi-node fleet each node holds its own members and a pub/sub layer delivers each broadcast to every node, as described in scaling WebSocket broadcast with Redis pub/sub. Before joining, run the policy check from per-channel authorization for WebSocket subscriptions.
Edge cases #
Broadcast during membership change. Iterating a Set while leave deletes from it is safe in JavaScript — deleted entries not yet visited are skipped — but joins during iteration may or may not be visited. If a newly joined member must not receive a message published before its join completed, snapshot membership with [...set] for that broadcast, or sequence joins and publishes through the same queue.
Presence and room size. size(room) is local to one node. A global member count needs a shared counter or a presence system; see building a WebSocket presence system with Redis.
Very large rooms. Past tens of thousands of members per node, even serialize-once broadcasting spends most of its time in the send loop. Split delivery across event-loop turns with setImmediate in chunks so heartbeats and inbound messages are not starved, or move the room to a dedicated node.
Verification #
Assert the invariant that matters — no references after close — in a unit test, and watch two gauges in production:
it('forgets a socket in every room when it closes', () => {
const reg = new RoomRegistry();
const ws = new EventEmitterSocket(); // test double with once/emit/readyState
reg.track(ws as any);
reg.join(ws as any, 'a'); reg.join(ws as any, 'b');
ws.emit('close');
expect(reg.size('a')).toBe(0);
expect(reg.roomCount()).toBe(0); // empty rooms were deleted too
});
In production, export rooms_total and the sum of room memberships per node. Memberships should rise and fall with connections; a membership count that keeps growing while connections are flat is a leak in some join path that bypasses track.
Operational checklist #
FAQ #
Can I use Socket.IO’s rooms with raw ws? #
No; Socket.IO rooms are part of its server and protocol. The registry above gives you equivalent semantics for raw ws, and the Redis adapter pattern gives you the multi-node part.
Should rooms be stored in Redis instead of memory? #
Store the authoritative subscription intent wherever you need durability, but deliver from an in-memory registry on each node — you cannot send to a socket from Redis. Redis carries broadcasts between nodes; memory holds the sockets.
How do I send to everyone except the sender? #
Pass the sender as except, as in the broadcast signature above. Clients that apply their own messages optimistically usually want this; clients that wait for the server echo do not.
What’s a reasonable per-socket room limit? #
It depends on the product: a chat client might need hundreds, a dashboard a handful. Pick a limit comfortably above real usage and enforce it, because an unbounded join is a cheap way for one client to consume memory.
Related #
- Building a WebSocket Message Router in TypeScript — the handlers that call join and broadcast.
- Multi-Tenant WebSocket Channel Namespacing — naming rooms safely.
- Per-Channel Authorization for WebSocket Subscriptions — the check before join.
- Scaling WebSocket Broadcast with Redis Pub/Sub — rooms across a fleet.
Back to Server-Side Routing Patterns.