Rooms and Broadcasting
How to send a message to one socket, one room, or every client in a namespace, and which form leaves out the sender.
server.to(room).emit(event, ...args) | client.join(room) | client.leave(room)Almost every real feature sends a message to some clients and not others. socket.io's tool for that is a room: a string label the server attaches to a socket. Clients cannot see rooms or join them on their own. Only server code can call client.join('order-42'), which makes rooms safe to name after things like an order id. Every socket already sits in one room named after its own id.
A namespace is the layer above: a separate communication channel chosen with @WebSocketGateway({ namespace: 'chat' }). Rooms live inside one namespace, and a broadcast never crosses namespaces.
Picking the audience
| Call | Who receives it |
|---|---|
client.emit(event, payload) | That one socket |
server.to(room).emit(...) | Every member of the room, sender included |
client.to(room).emit(...) | Every member except this socket |
server.emit(...) | Every socket in the namespace |
client.broadcast.emit(...) | Everyone except this socket |
server.in(room) is the same method as server.to(room), and client.broadcast.emit(x) is the same as server.except(client.id).emit(x). Chaining .to('a').to('b') is a union, and each socket still receives one copy.
Leaving
Sockets leave every room automatically when they disconnect, so there is no cleanup. socket.rooms is already empty in handleDisconnect(); the disconnecting event is the last point where it still holds the rooms.
Example
// src/chat/chat.gateway.ts
import {
ConnectedSocket,
MessageBody,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
WsException,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
WebSocketGateway({ namespace: 'chat' })
export class ChatGateway {
// With a namespace option this is a Namespace, not a Server. Both expose
// to(), in(), except(), emit() and fetchSockets().
WebSocketServer()
private readonly server: Server;
SubscribeMessage('room:join')
async join(
MessageBody('room') room: string,
ConnectedSocket() client: Socket,
) {
// Clients send a room name, so validate it before joining.
if (!/^room:[a-z0-9-]{1,40}$/.test(room)) {
throw new WsException('Unknown room');
}
// join() returns void with the in-memory adapter and a Promise with
// some others, so await it and the code works under both.
await client.join(room);
// to() from the socket: the other members hear it, the joiner does not.
client.to(room).emit('room:joined', { id: client.id });
const members = await this.server.in(room).fetchSockets();
return { room, members: members.length }; // acknowledgement to the joiner
}
SubscribeMessage('room:message')
message(
MessageBody() body: { room: string; text: string },
ConnectedSocket() client: Socket,
) {
// socket.rooms is the Set of rooms this socket is in.
if (!client.rooms.has(body.room)) {
throw new WsException('Join the room first');
}
// to() from the server: everyone including the sender, so every client
// renders the message from the same event rather than optimistically.
this.server.to(body.room).emit('room:message', {
from: client.id,
text: body.text,
at: Date.now(),
});
}
SubscribeMessage('room:leave')
async leave(
MessageBody('room') room: string,
ConnectedSocket() client: Socket,
) {
await client.leave(room);
this.server.to(room).emit('room:left', { id: client.id });
return { room, left: true };
}
}When to use it
- Send chat messages only to the people in that conversation.
- Stream the status of one order to the browsers watching that order's page.
- Show a live list of who is currently viewing a document.
- Disconnect every socket belonging to a user whose subscription just lapsed.
More examples
Listing who is in a room
Reach for this whenever the UI shows a presence list or a member count. Asking the adapter beats maintaining your own map of ids: it cannot drift when a socket dies without a clean disconnect, and it keeps working once a Redis adapter spreads the room across replicas.
// src/chat/roster.gateway.ts
import {
ConnectedSocket,
MessageBody,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@WebSocketGateway()
export class RosterGateway {
@WebSocketServer()
private readonly server: Server;
handleConnection(client: Socket) {
// socket.data (socket.io 4.0.0+) is a plain object for your own fields.
// fetchSockets() can read it, including on sockets from other replicas.
client.data.userId = 'u_' + client.id.slice(0, 6);
}
@SubscribeMessage('room:roster')
async roster(
@MessageBody('room') room: string,
@ConnectedSocket() client: Socket,
) {
// fetchSockets() was added in socket.io 4.0.0 and returns Socket or
// RemoteSocket objects. Prefer it over keeping your own Map.
const sockets = await this.server.in(room).fetchSockets();
return {
room,
you: client.data.userId as string,
// One user can have several tabs, so de-duplicate by user id.
users: [...new Set(sockets.map((s) => s.data.userId as string))],
};
}
}Moving or disconnecting sockets from server code
Use this shape when something outside the socket world has to change the socket world: an admin bans a user, a tournament round ends and everyone is moved to the next room, a tenant is suspended. server.in(room).disconnectSockets(true) is the blunt version when you want the whole room gone.
// src/moderation/moderation.gateway.ts
import { Injectable } from '@nestjs/common';
import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server } from 'socket.io';
@Injectable()
@WebSocketGateway()
export class ModerationGateway {
@WebSocketServer()
private readonly server: Server;
// Called from an HTTP controller or a queue processor, not from a message.
async moveToRoom(fromRoom: string, toRoom: string) {
// socketsJoin / socketsLeave, both socket.io 4.0.0+, act on every
// matching socket without needing the Socket objects themselves.
await this.server.in(fromRoom).socketsJoin(toRoom);
await this.server.in(fromRoom).socketsLeave(fromRoom);
this.server.to(toRoom).emit('room:moved', { from: fromRoom, to: toRoom });
}
async kick(userId: string) {
const sockets = await this.server.fetchSockets();
for (const socket of sockets) {
if (socket.data.userId === userId) {
socket.emit('kicked', { reason: 'moderation' });
// true closes the underlying connection rather than just the session.
socket.disconnect(true);
}
}
}
}Emitting from a controller or a queue processor
This is how an HTTP request or a background job reaches connected browsers. Keep the emit behind a named method on the gateway rather than exposing the raw server: callers then cannot invent event names, and the gateway stays the only place that knows the room naming scheme. Note that messages emitted this way bypass interceptors, which only see a handler's return value.
// src/orders/orders.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { ChatGateway } from '../chat/chat.gateway';
import { OrdersService } from './orders.service';
@Controller('orders')
export class OrdersController {
// The gateway is a provider, so inject it like a service. Its module must
// list it in exports and this module must import that module.
constructor(
private readonly orders: OrdersService,
private readonly chat: ChatGateway,
) {}
@Post(':id/ship')
async ship(@Body() body: { id: string }) {
const order = await this.orders.markShipped(body.id);
// Only the watchers of this order are notified.
this.chat.notifyRoom(`room:order-${order.id}`, 'order:shipped', order);
return order;
}
}
// src/chat/chat.gateway.ts (the method used above)
// notifyRoom(room: string, event: string, payload: unknown) {
// this.server.to(room).emit(event, payload);
// }
Discussion