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
providersarray 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).
Source: NestJS: Gateways
Installation and platforms
npm i --save @nestjs/websockets @nestjs/platform-socket.io
# or, for plain WebSockets
npm i --save @nestjs/websockets @nestjs/platform-ws
Source: NestJS: Gateways - Installation
@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 |
|
The native |
Message framing |
Named events, any number of arguments, acknowledgements |
JSON |
Namespaces and rooms |
Yes |
No namespaces (the adapter throws on the |
Reconnection, heartbeats |
Built in |
Yours to implement |
Acknowledgements |
Yes (callback) |
No: reply with a named message instead |
Adapter class |
|
|
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).
Source: NestJS: Adapters
@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) |
|---|---|
|
Socket.IO namespace as a string or |
|
URL path Socket.IO captures ( |
|
|
|
CORS options for the Socket.IO handshake. |
|
Heartbeat: ms between pings (25 000) and ms without a pong before the connection is dropped (20 000). |
|
Ms before a client that never joins the namespace is closed (45 000). |
|
Largest message accepted, in bytes (1 000 000); protects against oversized payloads. |
|
Function |
|
Serve the client bundle ( |
|
Allow upgrading from polling to WebSocket ( |
|
Compression of WebSocket frames (off) and of the polling transport (on). |
|
Advanced Socket.IO settings (pluggable room adapter, custom parser, |
@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
Source: NestJS: Gateways - Overview
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, |
The Socket.IO acknowledgement, only if the client passed a callback (or used |
|
No acknowledgement. Other falsy values ( |
|
A separate message named |
an |
One message per emitted value (typically |
a Promise |
The same, once it resolves (handlers may be |
nothing, with |
Nest sends no acknowledgement for you; call the function yourself. |
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;
}
}
Source: NestJS: Gateways
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 (
whoamiabove, called with a callback: it stayed unacknowledged). -
A message with no matching
@SubscribeMessage()is ignored silently, there is no error either side;emitWithAckon 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 (thepositionalhandler), 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 aWsResponseinstead when you want interceptors, such as theClassSerializerInterceptor, to apply. That interceptor ignores plain objects, so return an instance of a class that implementsWsResponse.
@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 |
|---|---|
|
Only that socket |
|
Everyone in the room except the sender |
|
Everyone in the room, sender included |
|
The union of the rooms |
|
Every socket of the namespace except the sender |
|
Every socket of the namespace |
|
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();
}
Source: Socket.IO: Rooms
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 |
|---|---|---|
|
|
Once, when the server (or namespace) is created and the gateway is registered. The place for server-wide middleware. |
|
|
For each new connection, after namespace middleware accepted it. |
|
|
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'));
}
});
}
Source: Socket.IO: Middlewares
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 |
Guards |
Same |
Interceptors |
Same |
Filters |
Catch |
Source: NestJS: Pipes (WebSockets)
Validation
The DTO (class-validator, as in Pipes and Validation) and a
ValidationPipe whose exceptionFactory converts failures to a WsException:
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;
}
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:
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;
}
}
Source: NestJS: Guards (WebSockets)
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:
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:
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.
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 };
}
}
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.
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 };
}
}
Source: NestJS: Gateways
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 {}
Source: NestJS: Gateways - Overview
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.
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
Source: Socket.IO: Client API
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
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;
}
}
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
Source: NestJS: Adapters - Ws library
import { WsAdapter } from '@nestjs/platform-ws';
const app = await NestFactory.create(AppModule);
app.useWebSocketAdapter(new WsAdapter(app));
Source: NestJS: Adapters - Ws library
Messages are JSON { "event": string, "data": any }. A gateway for it uses path to separate endpoints:
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 {}
Source: NestJS: Adapters - Ws library
With a native ws client on Express and on Fastify (FastifyAdapter):
| Client sends | Client receives |
|---|---|
|
|
|
|
|
|
invalid JSON |
nothing; the connection stays open (state |
connect to |
handshake fails ( |
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 };
},
});
Source: NestJS: Adapters - Ws library
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:
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: setcorsandallowRequestin 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 checkcontext.getType()first. -
Graceful shutdown:
app.close()closes the gateway servers through the adapter (close()anddispose()); 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 thereasonargument ofhandleDisconnect. Tutorials and books written for earlier versions describe gateways as singletons only andhandleDisconnect(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 withclient.emit()where a return value or aWsResponsewould also pass through interceptors. -
WsExceptionpayloads. Theexceptionevent has the shape{ status, message, cause }andBaseWsExceptionFiltercan drop or reshapecause. 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).
Source: NestJS: Migration guide
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
authpayload. -
Registering a global exception filter and expecting it to catch gateway errors; bind
@UseFilters()on the gateway. -
Throwing
BadRequestExceptionin a pipe: clients only seeInternal server error; throwWsException. -
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; useafterInit(). -
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
maxHttpBufferSizelow and validate sizes in the DTO (@Length). -
Leaving the default
corsopen (ororigin: '*') on an authenticated gateway. -
Under Vitest or any esbuild-based transformer,
emitDecoratorMetadatais 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.