WebSockets

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm @nestjs/websockets, @nestjs/platform-socket.io and @nestjs/platform-ws 12.1.2), Node.js 20.19+ or 22.12+, TypeScript 6, Socket.IO 4.8 (socket.io 4.8.4), ws 8 (8.22), Express 5.2 and Fastify 5.12, all checked on 2026-10-02. A WebSocket gives the browser and the server one long-lived, full-duplex connection: either side can send a message at any time, which is what a chat room, a live cursor or a trading screen needs. In Nest the unit that handles those messages is a gateway, a provider decorated with @WebSocketGateway() whose methods subscribe to named messages. Gateways are platform-agnostic: the same class runs on Socket.IO (the default adapter) or on plain ws, and the guards, pipes, interceptors and exception filters you already know apply to it with one difference, the error type (WsException instead of HttpException). The page builds the per-book chat of the Bookshelf running scenario step by step, then covers adapters and horizontal scaling.

The snippets target a NestJS 12.1.2 project (ESM, Vitest, TypeScript compiled with tsc --noEmit clean); the gateways behave the same over real sockets with socket.io-client and ws clients on both the Express and the Fastify adapter, and the outputs shown are what the clients receive. The Redis adapter example is tested with two Nest instances sharing an in-memory ioredis-mock pub/sub; run it against a real Redis before relying on it. Related pages: Server-Sent Events (the one-way alternative), Guards, Pipes and Validation, Interceptors, Exception Filters and Error Handling and Injection Scopes. The browser side of the protocol is described in Browser Networking, and testing gateways in Testing GraphQL, WebSockets and Microservices.

How a gateway fits in

A gateway is an ordinary provider (it can inject services through its constructor, and be injected) that Nest scans for message handlers when the module is initialized. Four things to keep in mind:

  • A gateway is not instantiated until it appears in the providers array of a module.

  • By default it shares the port of the HTTP server. Give a number as the first argument to listen on a separate port.

  • The decorator options are handed to the underlying library (Socket.IO server options for the default adapter).

  • Handlers are selected by a message name, not by a URL: the client emits send, the gateway has @SubscribeMessage('send'). A name typo on either side is not an error, the message is just ignored (shown below).

On the way in, a message goes through the same pipeline as an HTTP request: guards, interceptors (before), pipes on each handler argument, the handler, interceptors (after), and exception filters if anything throws. The connection itself is different: a guard does not run when a client connects, only when it sends a message, so connection-level authentication belongs in the adapter or in a handshake middleware (see Authenticating sockets).

Installation and platforms

npm i --save @nestjs/websockets @nestjs/platform-socket.io
# or, for plain WebSockets
npm i --save @nestjs/websockets @nestjs/platform-ws
@nestjs/platform-socket.io (default) @nestjs/platform-ws

Protocol

Socket.IO: its own protocol over WebSocket, with HTTP long-polling fallback

The standard WebSocket protocol

Browser client

socket.io-client (a plain WebSocket cannot talk to it)

The native WebSocket class

Message framing

Named events, any number of arguments, acknowledgements

JSON { "event": "…​", "data": …​ } by default (configurable)

Namespaces and rooms

Yes

No namespaces (the adapter throws on the namespace option); use path per gateway; no rooms

Reconnection, heartbeats

Built in

Yours to implement

Acknowledgements

Yes (callback)

No: reply with a named message instead

Adapter class

IoAdapter (the default, nothing to register)

WsAdapter, registered with app.useWebSocketAdapter()

Both adapters work on Express 5 and on Fastify 5; the gateway code does not change. On both platforms the gateways attach to the same HTTP server that app.listen() opens, so a single port serves REST and WebSockets. @nestjs/platform-socket.io pins an exact socket.io version (4.8.3 at 12.1.2); if your project also depends on socket.io at another version npm installs two copies, and the Server/Socket types of the two do not match. Keep the versions aligned (see the Redis adapter below, where it shows up as a type error).

@WebSocketGateway() options

The decorator accepts (), (port), (options) or (port, options). The options are the Socket.IO server options (GatewayMetadata in @nestjs/websockets):

Option Meaning (default)

namespace

Socket.IO namespace as a string or RegExp (/). With it, @WebSocketServer() injects a Namespace.

path

URL path Socket.IO captures (/socket.io). For ws it separates gateways.

transports

['polling', 'websocket']; use ['websocket'] to skip long-polling (needed for several instances without sticky sessions).

cors

CORS options for the Socket.IO handshake. app.enableCors() does not configure it.

pingInterval, pingTimeout

Heartbeat: ms between pings (25 000) and ms without a pong before the connection is dropped (20 000).

connectTimeout

Ms before a client that never joins the namespace is closed (45 000).

maxHttpBufferSize

Largest message accepted, in bytes (1 000 000); protects against oversized payloads.

allowRequest

Function (req, fn) that can reject the handshake or upgrade request before a socket exists.

serveClient

Serve the client bundle (true).

allowUpgrades, upgradeTimeout

Allow upgrading from polling to WebSocket (true) and the time budget for it (10 000).

perMessageDeflate, httpCompression

Compression of WebSocket frames (off) and of the polling transport (on).

adapter, parser, wsEngine, cookie, initialPacket, allowEIO3, destroyUpgrade, destroyUpgradeTimeout

Advanced Socket.IO settings (pluggable room adapter, custom parser, ws implementation, sticky-session cookie, v2 client compatibility, handling of unclaimed upgrades).

@WebSocketGateway(80, { namespace: 'events' })      // own port 80, namespace /events
@WebSocketGateway(81, { transports: ['websocket'] }) // own port, no long-polling
@WebSocketGateway({ transports: ['websocket'] })     // same port as the HTTP server

Handlers: @SubscribeMessage, @MessageBody, @ConnectedSocket, acknowledgements

@SubscribeMessage('name') marks a method as the handler for a message. The argument decorators come from @nestjs/websockets as well: @MessageBody() (optionally with a property key, @MessageBody('id')), @ConnectedSocket() and @Ack(). What the handler returns decides how the client is answered:

Handler returns What the client gets

a value (string, object, number, 0, false)

The Socket.IO acknowledgement, only if the client passed a callback (or used emitWithAck). Sent once.

null, undefined or nothing

No acknowledgement. Other falsy values (0, false, '') are still sent.

{ event, data } (WsResponse)

A separate message named event carrying data. Works on every adapter, can be repeated.

an Observable

One message per emitted value (typically WsResponse items) until it completes.

a Promise

The same, once it resolves (handlers may be async).

nothing, with @Ack() injected

Nest sends no acknowledgement for you; call the function yourself.

events.gateway.ts
import {
  Ack,
  ConnectedSocket,
  MessageBody,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
  type WsResponse,
} from '@nestjs/websockets';
import { type Observable, from, map } from 'rxjs';
import type { Server, Socket } from 'socket.io';

@WebSocketGateway({ transports: ['websocket'] })
export class EventsGateway {
  @WebSocketServer()
  server!: Server;

  // The returned value is sent back as the acknowledgement.
  @SubscribeMessage('echo')
  echo(@MessageBody() data: string): string {
    return data;
  }

  // A property key extracts one field of the payload.
  @SubscribeMessage('id')
  id(@MessageBody('id') id: number): number {
    return id;
  }

  // The socket is available through @ConnectedSocket(); nothing is returned, so no ack is sent.
  @SubscribeMessage('whoami')
  whoami(@ConnectedSocket() client: Socket): void {
    client.emit('you', { id: client.id });
  }

  // Manual acknowledgement.
  @SubscribeMessage('manual-ack')
  manualAck(
    @MessageBody() data: string,
    @Ack() ack: (response: { status: string; data: string }) => void,
  ): void {
    ack({ status: 'received', data });
  }

  // A WsResponse is emitted as a named event instead of an acknowledgement.
  @SubscribeMessage('named')
  named(@MessageBody() data: unknown): WsResponse<unknown> {
    return { event: 'named-reply', data };
  }

  // An Observable emits one event per value.
  @SubscribeMessage('stream')
  stream(): Observable<WsResponse<number>> {
    return from([1, 2, 3]).pipe(map((data) => ({ event: 'tick', data })));
  }

  // The positional (no decorators) form: client first, then data.
  @SubscribeMessage('positional')
  positional(client: Socket, data: string): string {
    return `${client.id.length > 0 ? 'ok' : 'no'}:${data}`;
  }

  // Falsy values other than null/undefined are still sent.
  @SubscribeMessage('zero')
  zero(): number {
    return 0;
  }
}

The client side, with socket.io-client, and what it printed in the test:

import { io } from 'socket.io-client';

const socket = io('http://localhost:3000', { transports: ['websocket'] });

await socket.emitWithAck('echo', 'hi');                  // 'hi'
await socket.emitWithAck('id', { id: 7, other: 1 });     // 7  (only the id property was extracted)
await socket.emitWithAck('manual-ack', 'x');             // { status: 'received', data: 'x' }
await socket.emitWithAck('zero');                        // 0

socket.on('named-reply', (data) => console.log(data));   // { a: 1 }  (WsResponse)
socket.emit('named', { a: 1 });

socket.on('tick', (n) => console.log(n));                // 1, 2, 3 (Observable)
socket.emit('stream');

Behaviours confirmed in the test on Express and Fastify:

  • A handler that returns nothing never fires the client’s acknowledgement callback (whoami above, called with a callback: it stayed unacknowledged).

  • A message with no matching @SubscribeMessage() is ignored silently, there is no error either side; emitWithAck on an unknown name just never resolves (the test raced it against a 150 ms timer). Add a client-side timeout (socket.timeout(ms).emit(…​)) so a typo or a lost message becomes visible.

  • The positional form handler(client, data) works (the positional handler), but the docs advise against it because unit tests must then fake the socket; prefer the decorators.

  • client.emit() inside a handler bypasses interceptors, which only see the handler’s return value. Return a WsResponse instead when you want interceptors, such as the ClassSerializerInterceptor, to apply. That interceptor ignores plain objects, so return an instance of a class that implements WsResponse.

@WebSocketServer(): server and namespace

The native server is injected into a property by @WebSocketServer(), and also passed to afterInit(). It is assigned once the gateway is ready, so do not read it in the constructor. Without a namespace option the type is Socket.IO’s Server; with one it is a Namespace, which offers the same emit, to, in, use and fetchSockets API for that namespace only.

@WebSocketGateway({ namespace: 'chat' })
export class ChatGateway {
  @WebSocketServer()
  namespace: Namespace; // a Server when no namespace is configured
}

Clients choose the namespace in the URL (io('http://host/chat')). Namespaces separate concerns on one connection (/chat, /notifications), each with its own handlers and its own middleware; rooms (next section) separate audiences inside a namespace. With the ws adapter neither concept exists: mount one gateway per path.

Rooms and broadcasting

A room is a server-side label a socket can join and leave; sending to a room reaches every member. Rooms are a Socket.IO feature, created on first join and gone when empty. A socket always sits in the room named by its own id, which makes to(socketId) a private message. The calls differ in who receives the message:

Call Recipients

client.emit(event, …​)

Only that socket

client.to(room).emit(event, …​)

Everyone in the room except the sender

namespace.to(room).emit(event, …​)

Everyone in the room, sender included

namespace.to(roomA).to(roomB).emit(…​)

The union of the rooms

client.broadcast.emit(event, …​)

Every socket of the namespace except the sender

namespace.emit(event, …​)

Every socket of the namespace

await namespace.in(room).fetchSockets()

The sockets in the room (across instances when a cluster adapter is configured)

The Bookshelf chat uses one room per book, book:<id>. Joining, leaving and listing members from the gateway:

@SubscribeMessage('join')
async join(@MessageBody() dto: JoinDto, @ConnectedSocket() client: Socket) {
  const room = `book:${dto.bookId}`;
  await client.join(room);
  // Everyone in the room except the sender.
  client.to(room).emit('presence', { user: client.data.user.name, joined: true });
  return { room };
}

@SubscribeMessage('leave')
async leave(@MessageBody() dto: JoinDto, @ConnectedSocket() client: Socket) {
  const room = `book:${dto.bookId}`;
  await client.leave(room);
  client.to(room).emit('presence', { user: client.data.user.name, joined: false });
  return { room };
}

// Who is in a room right now (on this instance; with a Redis adapter, across all of them).
@SubscribeMessage('members')
async members(@MessageBody() dto: JoinDto) {
  const sockets = await this.namespace.in(`book:${dto.bookId}`).fetchSockets();
  return sockets.map((s) => s.data.user.name).sort();
}

After Alice and Bob join book:1, members returns ["alice","bob"]; when Alice leaves, Bob receives {"user":"alice","joined":false} and members returns ["bob"]. Rooms are the only per-socket state Socket.IO keeps for you; put your own data on client.data (used below for the authenticated user), which also travels with the socket across a Redis adapter.

Lifecycle hooks

A gateway implements up to three hooks, each with an interface exported by @nestjs/websockets:

Interface Method Runs

OnGatewayInit

afterInit(server)

Once, when the server (or namespace) is created and the gateway is registered. The place for server-wide middleware.

OnGatewayConnection

handleConnection(client, …​args)

For each new connection, after namespace middleware accepted it.

OnGatewayDisconnect

handleDisconnect(client, reason?)

For each socket that closes. The socket has already left its rooms.

These are the gateway’s own hooks, separate from the module lifecycle (onModuleInit, onApplicationShutdown) covered in Lifecycle Events and Shutdown.

New in v12. handleDisconnect can receive the reason for the disconnection as a second argument. The interface types it as reason?: string. With Socket.IO, when the client calls socket.close(), it is client namespace disconnect. Other values are the Socket.IO reasons such as transport close, ping timeout and server namespace disconnect, which let you tell a deliberate sign-out from a network drop (to keep a user "away" instead of "gone", for example). With the ws adapter, on a normal close, the second argument is the numeric close code (1000), not a string.

handleDisconnect(client: Socket, reason?: string) {
  // client.rooms is already empty here: remember what you need on client.data
  this.logger.debug(`disconnected ${client.id}: ${reason}`);
}

Authenticating sockets

A guard protects a message, and nothing runs for the connection itself, so an unauthenticated client can connect and sit idle. Authenticate at the handshake instead, where the client can still be refused: register a Socket.IO middleware on the server or namespace in afterInit(). The client sends its token in the handshake auth payload (not in the URL, where it would end up in logs); the middleware verifies it and stores the user on socket.data. Rejecting with next(new Error(…​)) makes the client emit connect_error.

afterInit(namespace: Namespace) {
  // Authenticate at the handshake: guards only run for messages, not for the connection.
  namespace.use(async (socket, next) => {
    try {
      const token = socket.handshake.auth?.token as string | undefined;
      if (!token) throw new Error('missing token');
      const payload = await this.jwt.verifyAsync<{ sub: string; name: string }>(token);
      socket.data.user = { id: payload.sub, name: payload.name };
      next();
    } catch {
      next(new Error('unauthorized'));
    }
  });
}

The JWT secret comes from configuration (JwtModule.registerAsync with ConfigService.getOrThrow('JWT_SECRET'), shown in the module below), never from a literal. A client connecting to /chat without a token gets connect_error with the message unauthorized on both platforms. The same JWT strategy, now over HTTP, is in Authentication. Cookies are another option, since the handshake is an HTTP request: socket.handshake.headers.cookie carries them, but then CSRF-style attacks apply (a page on another origin can open a socket with the user’s cookies), so check Origin (the cors option and allowRequest) as well.

Pipes, guards, filters and interceptors

The four enhancers bind the same way as for controllers (@UsePipes(), @UseGuards(), @UseFilters(), @UseInterceptors() on a method or on the gateway class). What differs:

Enhancer Gateway specifics

Pipes

Throw WsException. Only a WsException reaches the client with its message; any other exception, including the BadRequestException the built-in validation pipes throw by default, is reported as Internal server error. Method-, gateway- and global pipes run on every handler argument, including the socket, so bind a pipe on a single argument (@MessageBody(new ParseIntPipe())) when only the payload should be transformed. Global pipes (app.useGlobalPipes(), APP_PIPE) apply to gateways.

Guards

Same CanActivate; context.switchToWs() gives getClient() and getData(). Returning false throws WsException('Forbidden resource'). Global guards apply to gateways.

Interceptors

Same NestInterceptor. They see only the value the handler returns (the acknowledgement or WsResponse), not what you client.emit(). Global interceptors apply.

Filters

Catch WsException (and everything else) and answer with an exception message. Global filters (useGlobalFilters, APP_FILTER) do not apply to gateways: bind @UseFilters() on the gateway or the method.

Validation

The DTO (class-validator, as in Pipes and Validation) and a ValidationPipe whose exceptionFactory converts failures to a WsException:

chat.dto.ts
import { IsInt, IsString, Length, Min } from 'class-validator';

export class JoinDto {
  @IsInt()
  @Min(1)
  bookId!: number;
}

export class SendMessageDto {
  @IsInt()
  @Min(1)
  bookId!: number;

  @IsString()
  @Length(1, 500)
  text!: string;
}
chat.pipe.ts
import { ValidationError, ValidationPipe } from '@nestjs/common';
import { WsException } from '@nestjs/websockets';

// Only a WsException reaches the client with its own message.
export const wsValidationPipe = () =>
  new ValidationPipe({
    whitelist: true,
    exceptionFactory: (errors: ValidationError[]) =>
      new WsException({
        message: 'Validation failed',
        errors: errors.map((e) => ({ field: e.property, constraints: e.constraints })),
      }),
  });

When a client sends join with { bookId: 'x' }, the client receives an exception event whose payload is the object given to WsException, unchanged:

{"message":"Validation failed","errors":[{"field":"bookId","constraints":{"min":"bookId must not be less than 1","isInt":"bookId must be an integer number"}}]}

(The errors are mapped to field and constraints: the raw ValidationError objects carry the whole target object and should not be sent to clients.) Schema libraries work too: @MessageBody({ schema }) with StandardSchemaValidationPipe and an exceptionFactory that returns a WsException, so Zod, Valibot or ArkType schemas validate WebSocket payloads.

@UsePipes(
  new StandardSchemaValidationPipe({
    exceptionFactory: (issues) => new WsException(issues),
  }),
)
@SubscribeMessage('events')
handleEvent(@MessageBody({ schema: createEventSchema }) data: CreateEventDto) {
  return data;
}

Run with a Zod schema (z.object({ name: z.string().min(1), seats: z.number().int().positive() })) on /schema: { name: 'n', seats: 3 } was acknowledged unchanged, and { name: '', seats: -1 } produced an exception event whose payload is the array of Standard Schema issues (each with path and message), emitted as is because it is the WsException argument. Schema validation is covered in Pipes and Validation.

Guard

The guard below allows send only to an authenticated socket that has joined the book’s room, so a client cannot post to a room it is not in just by guessing the id:

room-member.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { WsException } from '@nestjs/websockets';
import type { Socket } from 'socket.io';

@Injectable()
export class RoomMemberGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const client = context.switchToWs().getClient<Socket>();
    const data = context.switchToWs().getData<{ bookId?: number }>();
    if (!client.data.user) {
      throw new WsException('Unauthenticated');
    }
    if (!client.rooms.has(`book:${data.bookId}`)) {
      throw new WsException('Join the book room first');
    }
    return true;
  }
}

Sending send before joining produces {"status":"error","message":"Join the book room first","cause":{"pattern":"send","data":{"bookId":1,"text":"too early"}}}. The guard runs before the pipe here, so the payload it reads is unvalidated: treat getData() as untrusted.

Interceptor

An interceptor that wraps the acknowledgement of send in an envelope. It does not touch the message events emitted to the room, because those are emit calls, not return values:

envelope.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';

// Interceptors see the handler's return value (the acknowledgement), not client.emit() calls.
@Injectable()
export class AckEnvelopeInterceptor implements NestInterceptor {
  intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
    return next.handle().pipe(map((data) => ({ ok: true, data })));
  }
}

Exception filter and the error payload

WsException('text') makes Nest emit an exception event:

{"status":"error","message":"Invalid credentials.","cause":{"pattern":"ws","data":{"k":1}}}

cause tells the client which message failed. A WsException created with an object emits that object as is (the validation example above). Anything else thrown, Error or BadRequestException, gives the same shape with the message Internal server error (for both). With the ws adapter, which has no named events, the client receives a JSON text { "event": "exception", "data": …​ }.

BaseWsExceptionFilter is the built-in filter. Extend it to add logging or metrics and delegate with super.catch(); its constructor accepts includeCause (default true) and a causeFactory(pattern, data) that shapes cause:

chat.exception-filter.ts
import { ArgumentsHost, Catch, Logger } from '@nestjs/common';
import { BaseWsExceptionFilter } from '@nestjs/websockets';

@Catch()
export class ChatExceptionFilter extends BaseWsExceptionFilter {
  private readonly logger = new Logger(ChatExceptionFilter.name);
  static count = 0;

  catch(exception: unknown, host: ArgumentsHost) {
    ChatExceptionFilter.count++;
    this.logger.debug(`ws exception: ${(exception as Error).message}`);
    super.catch(exception, host); // emits the "exception" event to the client
  }
}

Omitting the cause (useful when the payload may be large or sensitive) produces {"status":"error","message":"Invalid credentials."}:

@UseFilters(new BaseWsExceptionFilter({ includeCause: false }))
@WebSocketGateway({ namespace: 'errors', transports: ['websocket'] })
export class QuietErrorsGateway {
  @SubscribeMessage('ws')
  ws() {
    throw new WsException('Invalid credentials.');
  }
}

Request-scoped gateways (v12)

Until v12 gateways could only be singletons. From v12 they support request-scoped providers: Nest creates a new instance of every request-scoped dependency per connected socket, injects the socket itself through the REQUEST token, keeps the instance for the life of the connection and tears it down when the socket disconnects. It is per connection, not per message, so it is the right place for per-connection state such as a sequence counter or a typing indicator.

connection-state.service.ts
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import type { Socket } from 'socket.io';

// One instance per connected socket (v12).
@Injectable({ scope: Scope.REQUEST })
export class ConnectionStateService {
  private sequence = 0;
  static created = 0;

  constructor(@Inject(REQUEST) private readonly client: Socket) {
    ConnectionStateService.created++;
  }

  next() {
    return { clientId: this.client.id, sequence: ++this.sequence };
  }
}
state.gateway.ts
import { Inject } from '@nestjs/common';
import { OnGatewayConnection, OnGatewayInit, SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
import type { Socket } from 'socket.io';
import { ConnectionStateService } from './connection-state.service.js';

// Depending on a request-scoped provider makes the gateway itself request-scoped.
@WebSocketGateway({ namespace: 'state', transports: ['websocket'] })
export class StateGateway implements OnGatewayInit, OnGatewayConnection {
  static afterInitSawState: boolean | undefined;

  constructor(@Inject(ConnectionStateService) private readonly state: ConnectionStateService) {}

  afterInit() {
    // Runs once on a shell instance: constructor dependencies are not available here.
    StateGateway.afterInitSawState = this.state !== undefined;
  }

  handleConnection(client: Socket) {
    client.emit('connected', this.state.next());
  }

  @SubscribeMessage('state')
  onState() {
    return { event: 'state', data: this.state.next() };
  }
}

With two clients on /state, each receives connected with sequence: 1; a client’s later state messages return 2 and 3 while the other client’s first returns 2 (independent counters), clientId equals the socket id, and exactly two ConnectionStateService instances are created for the two sockets.

Request scope bubbles up: a gateway that depends on a request-scoped provider is itself request-scoped, with its own instance per socket (see Injection Scopes). The consequence is that afterInit() runs once on a shell instance on which the constructor-injected dependency is undefined (StateGateway.afterInitSawState is false). So keep anything that needs afterInit() and shared dependencies, such as the handshake middleware above, in a singleton gateway, and put the per-connection state in a separate, request-scoped gateway (here /state), the structure this page’s chat uses. Use the default singleton scope unless you need per-connection state; request-scoped providers cost an instantiation per connection.

The Bookshelf per-book chat

Putting it together: members of a book’s page chat in the room book:<id> of the /chat namespace. The gateway authenticates at the handshake, validates every payload, lets only room members post, broadcasts to the room and announces who joins or leaves.

chat.gateway.ts
import { Inject, Logger, UseFilters, UseGuards, UseInterceptors, UsePipes } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import {
  ConnectedSocket,
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import type { Namespace, Socket } from 'socket.io';
import { AckEnvelopeInterceptor } from './envelope.interceptor.js';
import { ChatExceptionFilter } from './chat.exception-filter.js';
import { JoinDto, SendMessageDto } from './chat.dto.js';
import { wsValidationPipe } from './chat.pipe.js';
import { RoomMemberGuard } from './room-member.guard.js';

@UseFilters(new ChatExceptionFilter())
@UsePipes(wsValidationPipe())
@WebSocketGateway({ namespace: 'chat', transports: ['websocket'], cors: { origin: false } })
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
  private readonly logger = new Logger(ChatGateway.name);
  static disconnects: Array<{ id: string; reason?: string }> = [];

  // With a namespace option this is a Namespace, not a Server.
  @WebSocketServer()
  namespace!: Namespace;

  constructor(@Inject(JwtService) private readonly jwt: JwtService) {}

  afterInit(namespace: Namespace) {
    // Authenticate at the handshake: guards only run for messages, not for the connection.
    namespace.use(async (socket, next) => {
      try {
        const token = socket.handshake.auth?.token as string | undefined;
        if (!token) throw new Error('missing token');
        const payload = await this.jwt.verifyAsync<{ sub: string; name: string }>(token);
        socket.data.user = { id: payload.sub, name: payload.name };
        next();
      } catch {
        next(new Error('unauthorized'));
      }
    });
  }

  handleConnection(client: Socket) {
    this.logger.debug(`connected ${client.id} as ${client.data.user.name}`);
  }

  handleDisconnect(client: Socket, reason?: string) {
    ChatGateway.disconnects.push({ id: client.id, reason });
    // The socket has left every room by now; tell the others who is gone.
    this.logger.debug(`disconnected ${client.id}: ${reason}`);
  }

  @SubscribeMessage('join')
  async join(@MessageBody() dto: JoinDto, @ConnectedSocket() client: Socket) {
    const room = `book:${dto.bookId}`;
    await client.join(room);
    // Everyone in the room except the sender.
    client.to(room).emit('presence', { user: client.data.user.name, joined: true });
    return { room };
  }

  @SubscribeMessage('leave')
  async leave(@MessageBody() dto: JoinDto, @ConnectedSocket() client: Socket) {
    const room = `book:${dto.bookId}`;
    await client.leave(room);
    client.to(room).emit('presence', { user: client.data.user.name, joined: false });
    return { room };
  }

  // Who is in a room right now (on this instance; with a Redis adapter, across all of them).
  @SubscribeMessage('members')
  async members(@MessageBody() dto: JoinDto) {
    const sockets = await this.namespace.in(`book:${dto.bookId}`).fetchSockets();
    return sockets.map((s) => s.data.user.name).sort();
  }

  @UseGuards(RoomMemberGuard)
  @UseInterceptors(AckEnvelopeInterceptor)
  @SubscribeMessage('send')
  send(@MessageBody() dto: SendMessageDto, @ConnectedSocket() client: Socket) {
    const message = { bookId: dto.bookId, from: client.data.user.name, text: dto.text, at: Date.now() };
    // Everyone in the room, including the sender (namespace.to, not client.to).
    this.namespace.to(`book:${dto.bookId}`).emit('message', message);
    return { delivered: true };
  }
}
websockets.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { ChatGateway } from './chat.gateway.js';
import { ConnectionStateService } from './connection-state.service.js';
import { DefaultErrorsGateway, QuietErrorsGateway } from './errors.gateway.js';
import { SchemaGateway } from './schema.gateway.js';
import { StateGateway } from './state.gateway.js';
import { EventsGateway } from './events.gateway.js';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({ secret: config.getOrThrow<string>('JWT_SECRET') }),
    }),
  ],
  providers: [EventsGateway, ChatGateway, StateGateway, ConnectionStateService, QuietErrorsGateway, DefaultErrorsGateway, SchemaGateway],
})
export class WebsocketsModule {}

ConfigModule makes JWT_SECRET available from the environment (generate one with openssl rand -hex 32); the EventsGateway, StateGateway, SchemaGateway, QuietErrorsGateway and DefaultErrorsGateway are the other gateways shown on this page. Because the module registers all of them, nothing else is needed in main.ts; the gateways attach to the HTTP server. The static disconnects and count fields in the gateway and the filter are only test hooks to observe the reasons and the filter calls; drop them in production code.

The sequence below is what the test ran: authenticated connect, join, message, broadcast, and disconnect.

sequenceDiagram participant A as Alice (client) participant B as Bob (client) participant G as ChatGateway (/chat) A->>G: connect with auth token G->>G: namespace.use() verifies the JWT, sets socket.data.user G->>G: handleConnection(client) B->>G: connect with auth token A->>G: join { bookId: 1 } G->>G: pipe validates, client.join("book:1") G-->>A: ack { room: "book:1" } B->>G: join { bookId: 1 } G-->>A: presence { user: "bob", joined: true } G-->>B: ack { room: "book:1" } A->>G: send { bookId: 1, text } G->>G: guard checks the room, pipe validates G-->>A: message (namespace.to room) G-->>B: message (namespace.to room) G-->>A: ack { ok: true, data: { delivered: true } } A--xG: close G->>G: handleDisconnect(client, reason) Note over G,B: client namespace disconnect, the socket has left book:1
Figure 1. Connect, join, message, broadcast, disconnect

A client in the browser or Node:

import { io } from 'socket.io-client';

const socket = io('http://localhost:3000/chat', {
  transports: ['websocket'],
  auth: { token: await getAccessToken() }, // the same JWT used for the REST API
});

socket.on('connect_error', (err) => console.error(err.message)); // "unauthorized"
socket.on('presence', (p) => console.log(p));                    // { user: 'bob', joined: true }
socket.on('message', (m) => console.log(m));                     // { bookId, from, text, at }

await socket.emitWithAck('join', { bookId: 1 });                 // { room: 'book:1' }
const ack = await socket.emitWithAck('send', { bookId: 1, text: 'Loved chapter 3' });
// { ok: true, data: { delivered: true } } thanks to the interceptor

On both platforms: when Bob joins, Alice receives presence with {"user":"bob","joined":true}; Alice and Bob both receive {"bookId":1,"from":"alice","text":"hello","at":…​}; the sender’s acknowledgement is {"ok":true,"data":{"delivered":true}}; and after Alice closes her socket handleDisconnect receives client namespace disconnect.

The send handler broadcasts with this.namespace.to(room), which includes the sender. Using client.to(room) would exclude them, which is what presence does. Persisting messages to the database, paginating history and fanning out to a mobile push service are ordinary services injected into the gateway; the gateway only translates messages.

Adapters

Gateways talk to the library through a WebSocketAdapter. Register a different one with app.useWebSocketAdapter() before listen(). The default is IoAdapter (Socket.IO).

Scaling Socket.IO: Redis adapter

With one instance, rooms live in that process’s memory. Behind a load balancer with several instances a message to book:1 reaches only the sockets connected to the instance that emitted it. The fix is a cluster adapter that relays broadcasts between instances through a broker. The official recipe extends IoAdapter and installs the Redis adapter on each Socket.IO server it creates:

npm i --save redis socket.io @socket.io/redis-adapter
redis-io.adapter.ts
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

export class RedisIoAdapter extends IoAdapter {
  private adapterConstructor!: ReturnType<typeof createAdapter>;

  // The URL comes from configuration (for example ConfigService.getOrThrow('REDIS_URL')).
  async connectToRedis(url: string): Promise<void> {
    const pubClient = createClient({ url });
    const subClient = pubClient.duplicate();

    await Promise.all([pubClient.connect(), subClient.connect()]);

    this.useClients(pubClient, subClient);
  }

  // Also handy for tests: pass any two connected clients.
  useClients(pubClient: Parameters<typeof createAdapter>[0], subClient: Parameters<typeof createAdapter>[1]): void {
    this.adapterConstructor = createAdapter(pubClient, subClient);
  }

  createIOServer(port: number, options?: Parameters<IoAdapter['createIOServer']>[1]): any {
    const server = super.createIOServer(port, options);
    server.adapter(this.adapterConstructor);
    return server;
  }
}
main.ts
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { RedisIoAdapter } from './websockets/redis-io.adapter.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const redisIoAdapter = new RedisIoAdapter(app);
  await redisIoAdapter.connectToRedis(app.get(ConfigService).getOrThrow<string>('REDIS_URL'));
  app.useWebSocketAdapter(redisIoAdapter);

  await app.listen(3000);
}
bootstrap();

The useClients() method added to the documented class here separates connecting from wiring the adapter, which is what lets a test run two Nest instances against an in-memory ioredis-mock pair instead of a Redis server. With two applications on different ports, each with its own adapter instance built from its own mock pub and sub clients, a client connected to instance B receives hello that the server of instance A emits. That exercises the relay logic and the Nest wiring, not Redis itself.

The Redis adapter does not remove the other multi-instance requirement. Socket.IO’s HTTP long-polling opens several requests per session that must reach the same instance, so a load balancer needs either sticky sessions (cookie-based routing) or clients that skip polling with transports: ['websocket']. Redis alone is not enough. With the adapter in place the room-wide calls such as namespace.in(room).fetchSockets() also see sockets connected to the other instances (the Socket.IO documentation lists the cluster-aware server methods).

Typing note from the test project: createIOServer in the documented class is typed with Socket.IO’s ServerOptions. If your project’s socket.io differs from the exact version pinned by @nestjs/platform-socket.io, the two copies' types clash and the override fails to compile; deriving the parameter type from the base class (Parameters<IoAdapter['createIOServer']>[1]) avoids it, as in the class above.

Plain WebSockets: WsAdapter

WsAdapter (from @nestjs/platform-ws) is a thin proxy over the ws library. It is fully compatible with the browser’s native WebSocket and has less overhead than Socket.IO, but also far fewer features: no namespaces (setting namespace throws), no rooms, no acknowledgements, no automatic reconnection.

npm i --save @nestjs/platform-ws
import { WsAdapter } from '@nestjs/platform-ws';

const app = await NestFactory.create(AppModule);
app.useWebSocketAdapter(new WsAdapter(app));

Messages are JSON { "event": string, "data": any }. A gateway for it uses path to separate endpoints:

ws-echo.gateway.ts
import { Module } from '@nestjs/common';
import {
  ConnectedSocket,
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
  SubscribeMessage,
  WebSocketGateway,
} from '@nestjs/websockets';
import type { WebSocket } from 'ws';

// With WsAdapter: no namespaces; use `path` to separate gateways.
@WebSocketGateway({ path: '/echo' })
export class WsEchoGateway implements OnGatewayConnection, OnGatewayDisconnect {
  static events: unknown[] = [];

  handleConnection(client: WebSocket) {
    WsEchoGateway.events.push(['connected', typeof client.send]);
  }

  handleDisconnect(client: WebSocket, ...rest: unknown[]) {
    WsEchoGateway.events.push(['disconnected', ...rest.map((r) => (Buffer.isBuffer(r) ? 'buf' : r))]);
  }

  // The reply of a handler is sent as {event, data} if you return a WsResponse.
  @SubscribeMessage('echo')
  echo(@MessageBody() data: unknown, @ConnectedSocket() _client: WebSocket) {
    return { event: 'echo', data };
  }

  // A plain return value is serialized as is.
  @SubscribeMessage('plain')
  plain(@MessageBody() data: unknown) {
    return data;
  }
}

@Module({ providers: [WsEchoGateway] })
export class WsEchoModule {}

With a native ws client on Express and on Fastify (FastifyAdapter):

Client sends Client receives

{"event":"echo","data":{"a":1}}

{"event":"echo","data":{"a":1}} (a WsResponse, framed as is)

{"event":"plain","data":"x"}

"x" (a plain return value is just JSON-serialized, with no event wrapper)

{"event":"echo"}

{"event":"echo"} (missing data is omitted)

invalid JSON

nothing; the connection stays open (state OPEN)

connect to /other

handshake fails (socket hang up): only /echo is served

Because there is no acknowledgement mechanism, answer with a WsResponse so the reply carries its own event name. With ws, @ConnectedSocket() is the ws WebSocket, and handleDisconnect received 1000 for a normal close, as noted above.

If your clients send another format, pass a messageParser, or call setMessageParser() on the adapter later. The test sent ["echo", 5] and got {"event":"echo","data":5}:

const wsAdapter = new WsAdapter(app, {
  // To handle messages in the [event, data] format
  messageParser: (data) => {
    const [event, payload] = JSON.parse(data.toString());
    return { event, data: payload };
  },
});

Custom adapters

For another library, implement the WebSocketAdapter interface: create builds the server, bindClientConnect and (optionally) bindClientDisconnect hook connections, bindMessageHandlers routes incoming messages to handlers, and close stops the server. The documented example integrates ws by hand (use the real WsAdapter in practice); with the current ws typings it reads:

custom-ws.adapter.ts
import WebSocket, { WebSocketServer } from 'ws';
import { WebSocketAdapter, INestApplicationContext } from '@nestjs/common';
import { MessageMappingProperties } from '@nestjs/websockets';
import { Observable, fromEvent, EMPTY } from 'rxjs';
import { mergeMap, filter } from 'rxjs/operators';

export class CustomWsAdapter implements WebSocketAdapter {
  constructor(private app: INestApplicationContext) {}

  create(port: number, options: any = {}): any {
    return new WebSocketServer({ port, ...options });
  }

  bindClientConnect(server: WebSocketServer, callback: (...args: any[]) => void) {
    server.on('connection', callback);
  }

  bindMessageHandlers(
    client: WebSocket,
    handlers: MessageMappingProperties[],
    process: (data: any) => Observable<any>,
  ) {
    fromEvent(client, 'message')
      .pipe(
        mergeMap((data) => this.bindMessageHandler(data, handlers, process)),
        filter((result) => result),
      )
      .subscribe((response) => client.send(JSON.stringify(response)));
  }

  bindMessageHandler(
    buffer: any,
    handlers: MessageMappingProperties[],
    process: (data: any) => Observable<any>,
  ): Observable<any> {
    const message = JSON.parse(buffer.data);
    const messageHandler = handlers.find((handler) => handler.message === message.event);
    if (!messageHandler) {
      return EMPTY;
    }
    return process(messageHandler.callback(message.data));
  }

  close(server: WebSocketServer) {
    server.close();
  }

  // Called once when the application shuts down. It is not part of the WebSocketAdapter
  // interface, but Nest 12.1 calls it, so a custom adapter has to define it.
  dispose() {}
}

Two details found while running it. First, a numeric first argument of @WebSocketGateway(port) is what reaches create(port): the test gateway listens on its own port instead of sharing the HTTP server’s. Second, Nest 12.1 calls adapter.dispose() when the application closes, though the WebSocketAdapter interface does not list it: a custom adapter without it made app.close() throw adapter?.dispose is not a function, so define it (it may be empty) or extend AbstractWsAdapter from @nestjs/websockets, which provides it. The test then received {"event":"echo","data":[1,2]}.

Register it exactly like the others:

const app = await NestFactory.create(AppModule);
app.useWebSocketAdapter(new CustomWsAdapter(app));

Express and Fastify

Gateways do not depend on the HTTP platform: they attach to the server the platform creates, and the same module ran unchanged on @nestjs/platform-express and @nestjs/platform-fastify in the tests, with identical observable behaviour for Socket.IO and for ws (the [::1] address Fastify reports for getUrl() has to be replaced by localhost when a client connects). The platform matters in three places:

  • HTTP middleware, app.enableCors() and the platform’s CSP/helmet configuration do not apply to the WebSocket handshake Socket.IO handles itself: set cors and allowRequest in the gateway options.

  • Global guards, pipes and interceptors also reach gateways, while global exception filters do not (see Pipes, guards, filters and interceptors), so a guard written for HTTP must read the socket through switchToWs() or check context.getType() first.

  • Graceful shutdown: app.close() closes the gateway servers through the adapter (close() and dispose()); see Lifecycle Events and Shutdown.

What changed recently, and where older material is outdated

  • v12.0 (2026-08-27) added request-scoped gateways (the socket is injectable with REQUEST) and the reason argument of handleDisconnect. Tutorials and books written for earlier versions describe gateways as singletons only and handleDisconnect(client) with a single argument.

  • Acknowledgements are first-class. @Ack() gives manual control over the acknowledgement and tells Nest not to send one from the return value. Older material often emits a reply with client.emit() where a return value or a WsResponse would also pass through interceptors.

  • WsException payloads. The exception event has the shape { status, message, cause } and BaseWsExceptionFilter can drop or reshape cause. Older examples show only the message.

  • Guards are for messages. A guard never runs for the connection (handleConnection), so authentication that must refuse a client belongs in a handshake middleware or in the adapter’s server options.

  • Global filters do not reach gateways, unlike pipes, guards and interceptors.

  • Standard Schema validation works with @MessageBody({ schema }), in addition to class-validator.

  • Documentation URLs. The WebSockets chapter lives under /websockets/* (gateways, exception filters, pipes, guards, interceptors, adapter).

  • Names must match: the book’s errata include mismatched route, event and port names, which here fails silently. Nothing reports an unknown message name, so verify names in tests (see Testing GraphQL, WebSockets and Microservices).

Pitfalls

  • Authenticating only with a guard: the socket is already connected; use handshake middleware (see Authenticating sockets).

  • Putting tokens in the query string, where they reach logs; use the handshake auth payload.

  • Registering a global exception filter and expecting it to catch gateway errors; bind @UseFilters() on the gateway.

  • Throwing BadRequestException in a pipe: clients only see Internal server error; throw WsException.

  • Several instances without a cluster adapter, or with a Redis adapter but with long-polling and no sticky sessions.

  • Reading @WebSocketServer() in a constructor: it is assigned after the gateway is created; use afterInit().

  • A request-scoped dependency in the authentication gateway: its afterInit() runs without injected dependencies.

  • Forgetting to list the gateway in a module’s providers: it is never instantiated, so its messages are never handled.

  • Trusting client-supplied ids: check room membership and ownership server-side, as the guard above does.

  • Unbounded payloads: keep maxHttpBufferSize low and validate sizes in the DTO (@Length).

  • Leaving the default cors open (or origin: '*') on an authenticated gateway.

  • Under Vitest or any esbuild-based transformer, emitDecoratorMetadata is not honoured, so constructor injection by type fails; the classes above use explicit @Inject(Token). The Nest CLI build (tsc/SWC) does not need it.