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.

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

CallWho 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

Example · typescript
// 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.

Example · typescript
// 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.

Example · typescript
// 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.

Example · typescript
// 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

  • Be the first to comment on this lesson.