Skip to content
NestJS ns websockets 5 min read

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.IO Namespace, not the root Server. 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.

ExpressionRecipients
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.

NamespaceRoom
GranularityCoarse, app-level channelFine, dynamic grouping
DefinedStatically on the gatewayCreated on demand at runtime
Client awarenessClient chooses URL to connectServer-managed, often invisible
MembershipOne per connectionMany per socket
Typical use/chat, /admin, /notificationsroom: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 client socket to exclude the sender and from the server/namespace to include them — pick deliberately to avoid echoing a user’s own message.
  • Always await join() and leave(); 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.
Last updated June 14, 2026
Was this helpful?