Rooms & Namespaces
Once you have more than a handful of connected clients, broadcasting to everyone stops being useful. A chat app needs to deliver a message only to people in the same conversation; a dashboard needs to push updates only to subscribers of a particular tenant. Socket.IO solves this with two complementary primitives: namespaces, which split your server into independent communication channels, and rooms, which group sockets within a namespace so you can target subsets of clients. NestJS exposes both directly through gateways.
Namespaces
A namespace is a named endpoint on a single underlying connection — think of it as a logical partition of your WebSocket server. Clients connect to a specific namespace, and events emitted on one namespace never leak into another. You declare a namespace by passing it to @WebSocketGateway().
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
MessageBody,
} from '@nestjs/websockets';
import { Namespace } from 'socket.io';
@WebSocketGateway({ namespace: 'chat', cors: { origin: '*' } })
export class ChatGateway {
// Note: the injected server is a Namespace, not the root Server
@WebSocketServer()
namespace: Namespace;
@SubscribeMessage('announce')
announce(@MessageBody() text: string): void {
// Reaches every socket connected to the /chat namespace only
this.namespace.emit('announcement', text);
}
}
Clients connect to it by appending the namespace to the connection URL:
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000/chat');
Tip: When a gateway declares a
namespace, the object injected by@WebSocketServer()is a Socket.IONamespace, not the rootServer. Calling.emit()on it broadcasts only within that namespace — exactly what you usually want.
Rooms
A room is an arbitrary string-keyed channel inside a namespace. A socket can belong to many rooms at once, and you can emit to a room to reach every member without tracking IDs yourself. Sockets automatically join a room named after their own id, which is how Socket.IO delivers direct messages.
You manage membership with socket.join() and socket.leave(), and you broadcast with server.to(room).emit().
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
interface JoinPayload {
room: string;
user: string;
}
@WebSocketGateway({ cors: { origin: '*' } })
export class RoomGateway {
@WebSocketServer()
server: Server;
@SubscribeMessage('joinRoom')
async joinRoom(
@MessageBody() { room, user }: JoinPayload,
@ConnectedSocket() client: Socket,
): Promise<void> {
await client.join(room);
// Notify others already in the room (excludes the sender)
client.to(room).emit('userJoined', { user, room });
}
@SubscribeMessage('leaveRoom')
async leaveRoom(
@MessageBody() room: string,
@ConnectedSocket() client: Socket,
): Promise<void> {
await client.leave(room);
client.to(room).emit('userLeft', { id: client.id, room });
}
@SubscribeMessage('roomMessage')
roomMessage(
@MessageBody() { room, user }: JoinPayload,
@ConnectedSocket() client: Socket,
): void {
// Include the sender by emitting from the server, not the client
this.server.to(room).emit('message', { user, from: client.id });
}
}
Output:
[Nest] LOG [RoomGateway] socket q7Xz1aB3 joined room "general"
[Nest] LOG [RoomGateway] emit "userJoined" -> room "general"
[Nest] LOG [RoomGateway] emit "message" -> room "general" (3 members)
Targeting: who receives what
The key distinction is who you call .to() on. Emitting from the server (or namespace) reaches everyone in the room including the sender; emitting from the client (the connected Socket) excludes the sender. You can also chain .to() calls to address the union of several rooms.
| Expression | Recipients |
|---|---|
server.emit('e') | Every socket in the namespace |
server.to('room1').emit('e') | Everyone in room1 (sender included) |
client.to('room1').emit('e') | Everyone in room1 except the sender |
server.to('r1').to('r2').emit('e') | Union of r1 and r2 (each socket once) |
client.emit('e') | Only the sender |
server.to(client.id).emit('e') | A single specific socket (private message) |
server.except('r1').emit('e') | Everyone except members of r1 |
Inspecting and managing rooms
The server adapter tracks membership, so you can query it for monitoring, presence indicators, or capacity limits. These methods are asynchronous because in a multi-node setup the answer may live in another process.
@SubscribeMessage('roomStats')
async roomStats(@MessageBody() room: string) {
const sockets = await this.server.in(room).fetchSockets();
return {
room,
count: sockets.length,
members: sockets.map((s) => s.id),
};
}
@SubscribeMessage('kickAll')
disconnectRoom(@MessageBody() room: string): void {
// Force every socket in the room to disconnect
this.server.in(room).disconnectSockets(true);
}
Warning: Room membership is stored in the adapter’s memory. With multiple Node instances behind a load balancer, the default in-memory adapter cannot route a
server.to(room)emit to sockets on another instance. You must install a shared adapter (for example@socket.io/redis-adapter) so rooms work cluster-wide.
Namespaces vs rooms
They are not competing features — you typically use both together. A namespace separates unrelated concerns at the protocol level; a room subdivides clients within one concern.
| Namespace | Room | |
|---|---|---|
| Granularity | Coarse, app-level channel | Fine, dynamic grouping |
| Defined | Statically on the gateway | Created on demand at runtime |
| Client awareness | Client chooses URL to connect | Server-managed, often invisible |
| Membership | One per connection | Many per socket |
| Typical use | /chat, /admin, /notifications | room:42, tenant:acme, a user’s id |
Best practices
- Use namespaces for broad, static separation (admin vs. public, distinct features) and rooms for dynamic, per-entity grouping like a single chat or document.
- Emit from the
clientsocket to exclude the sender and from theserver/namespace to include them — pick deliberately to avoid echoing a user’s own message. - Always
awaitjoin()andleave(); in clustered deployments they are asynchronous and ignoring the promise can introduce races. - Name rooms with stable, namespaced keys (
order:123,user:42) rather than ad-hoc strings so they are easy to reason about and clean up. - Treat the room name in any payload as untrusted input — validate it with a DTO and pipe, and authorize membership before calling
join(). - Install a Redis (or other) adapter before scaling beyond one process; rooms and broadcasts silently fail across nodes without one.