How to implement WebSocket Gateways in NestJS?
WebSocket Gateway in NestJS is a class decorated with @WebSocketGateway() that creates a Socket.IO server endpoint for real-time, bidirectional communication - separate from your HTTP routes but sharing the same port.
Theory
TL;DR
- Gateways work like a hotel front desk for live conversations: HTTP handles one-off check-ins, the gateway keeps an ongoing back-and-forth without a new request each time.
- Core difference from controllers: gateways mount a Socket.IO server on a specific namespace; controllers handle REST only.
- HTTP and WebSocket share the same port through the Upgrade header mechanism.
- Live updates (chat, notifications, presence) - gateway. One-off data fetches - HTTP controller.
- For multiple server instances in production, you need a Redis adapter. Without it, broadcasts stay local to one process.
Quick example
Install the packages first:
npm install @nestjs/websockets @nestjs/platform-socket.io socket.ioimport { WebSocketGateway, SubscribeMessage, MessageBody, WebSocketServer } from '@nestjs/websockets';
import { Server } from 'socket.io';
@WebSocketGateway({ namespace: '/chat', cors: { origin: '*' } })
export class ChatGateway {
@WebSocketServer() server: Server;
@SubscribeMessage('sendMessage')
handleMessage(@MessageBody() data: string): void {
this.server.emit('newMessage', data); // broadcasts to all connected clients
}
}
// Register as a provider in app.module.ts, not as a controller
@Module({ providers: [ChatGateway] })
export class AppModule {}The client connects with io('http://localhost:3000/chat') and emits socket.emit('sendMessage', 'Hello!'). All connected clients receive the broadcast instantly.
Key difference from HTTP controllers
Controllers use Express/Fastify routers for stateless request-response. Gateways bootstrap a Socket.IO server instance tied to @WebSocketServer() and route events through namespaces. The underlying http.Server is shared: WebSocket Upgrade requests bypass HTTP routing, hit Socket.IO's handshake, and Engine.IO creates persistent TCP connections. Events route to handlers via decorator metadata. Lifecycle hooks like handleConnection fire on socket.on('connect').
When to use
- Typing indicators, user presence, cursor positions - gateway with namespaces.
- Load chat history or fetch a user profile - HTTP controller.
- More than 10 events per second per user - gateway with rooms.
- Stateless API with no real-time requirement - REST, no gateway needed.
- Multiple server instances in production - gateway plus a Redis adapter.
Authentication pattern
Pass a JWT in handshake.auth from the client. Validate it in handleConnection and store the result on client.data. For per-method auth, use @UseGuards() with a custom CanActivate that reads from context.switchToWs().getClient(). If validation fails, emit an error and call client.disconnect() before any handler runs.
Scaling with a Redis adapter
One NestJS process keeps all socket state in memory. Run two instances and this.server.to(room).emit() from one pod only reaches clients on that pod. The Redis adapter syncs broadcasts across instances through pub/sub.
Most teams hit this not during local development but the first time they deploy to Kubernetes - after that, the Redis adapter becomes non-negotiable.
Common mistakes
Wrong namespace in client connect:
// Wrong - connects to '/' namespace; no handlers fire
io('http://localhost:3000');
// Correct - match the namespace in @WebSocketGateway()
io('http://localhost:3000/chat');The connection appears successful but no events are handled. Logs show "connected", nothing else.
Broadcasting without validating the sender:
// Wrong - anyone can push to all clients
@SubscribeMessage('msg')
handle(@MessageBody() data: any) {
this.server.emit('msg', data);
}
// Correct - check client.data.user first
@SubscribeMessage('msg')
handle(@MessageBody() data: any, @ConnectedSocket() client: Socket) {
if (!client.data.user) return;
this.server.emit('msg', { userId: client.data.user.id, ...data });
}Missing handleDisconnect causes memory leaks:
// Wrong - Map grows forever; thousands of ghost entries after a connection spike
private users = new Map<string, UserInfo>();
handleConnection(client: Socket) { this.users.set(client.id, {}); }
// no handleDisconnect
// Correct
handleDisconnect(client: Socket) { this.users.delete(client.id); }cors: '*' in production opens cross-site WebSocket hijacking. Use { origin: ['https://myapp.com'], credentials: true } instead.
Deploying multiple instances without the Redis adapter: PM2 clusters and Kubernetes pods each keep their own socket state. Broadcasts drop silently for clients on other instances.
Real-world usage
- Chat apps (Rocket.Chat-style): one namespace per feature, rooms per conversation thread.
- Live dashboards: price ticks via namespaces, no polling.
- Notifications:
@nestjs/bulljob queue plus gateway emit on job completion. - Collaborative tools: cursor position broadcasting across connected clients.
- Multiplayer games: player state updates with a room per game session.
Follow-up questions
Q: How does NestJS handle WebSocket and HTTP on the same port?
A: Socket.IO intercepts the HTTP Upgrade header before Express/Fastify routing sees the request. If the handshake passes, Engine.IO keeps the TCP connection open. Normal HTTP requests go through the middleware stack as usual.
Q: What is the difference between rooms and namespaces?
A: Namespaces partition the Socket.IO server itself, like separate logical servers on one port. Rooms group clients within a namespace and support dynamic join and leave, but they do not isolate event handlers.
Q: How do you emit events from a service rather than directly from the gateway?
A: Inject the gateway into the service and call gateway.server.to(room).emit(...). Or use a shared event emitter the gateway subscribes to. The second approach avoids pulling the gateway dependency into unrelated services.
Q: What happens to room memberships when a client reconnects?
A: Socket.IO does not restore memberships automatically. Re-join rooms in handleConnection using stored state - a Redis hash or database record keyed by user ID.
Q: How would you handle sticky sessions for a load-balanced gateway deployment?
A: Configure Nginx or HAProxy to route by socket.id hash so a client always hits the same instance during handshake. Socket.IO v4.7+ also supports sticky: true on the server. Without sticky routing, the HTTP handshake and WebSocket upgrade can land on different instances and the connection drops. The Redis adapter is a separate concern: it syncs broadcasts but does not fix handshake routing.
Examples
Basic gateway with connection lifecycle
import {
WebSocketGateway, WebSocketServer, SubscribeMessage,
MessageBody, ConnectedSocket, OnGatewayConnection, OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({ namespace: '/chat', cors: { origin: '*' } })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server;
handleConnection(client: Socket) {
console.log('Client connected:', client.id);
}
handleDisconnect(client: Socket) {
console.log('Client disconnected:', client.id);
}
@SubscribeMessage('message')
handleMessage(
@MessageBody() data: { room: string; text: string },
@ConnectedSocket() client: Socket,
) {
this.server.to(data.room).emit('message', {
user: client.id,
text: data.text,
timestamp: new Date(),
});
}
@SubscribeMessage('joinRoom')
handleJoinRoom(@MessageBody() room: string, @ConnectedSocket() client: Socket) {
client.join(room);
this.server.to(room).emit('userJoined', { userId: client.id });
return { event: 'joinedRoom', data: room }; // acknowledgment back to sender
}
}handleConnection and handleDisconnect fire automatically. @SubscribeMessage('joinRoom') returns an acknowledgment object directly back to the calling client.
Chat with rooms and JWT authentication
import { UseGuards, Injectable } from '@nestjs/common';
import { CanActivate, ExecutionContext } from '@nestjs/common';
@Injectable()
export class WsAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const client = context.switchToWs().getClient<Socket>();
return !!client.data.user;
}
}
@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway implements OnGatewayConnection {
constructor(private readonly authService: AuthService) {}
@WebSocketServer() server: Server;
async handleConnection(client: Socket) {
try {
const token = client.handshake.auth.token;
const user = await this.authService.verifyToken(token);
client.data.user = user; // store for later handlers
client.join('general'); // auto-join default room
} catch {
client.emit('error', { message: 'Authentication failed' });
client.disconnect(); // close before any handler runs
}
}
@SubscribeMessage('joinRoom')
@UseGuards(WsAuthGuard)
joinRoom(@MessageBody() { room }: { room: string }, @ConnectedSocket() client: Socket) {
client.join(room);
client.emit('joined', `Welcome to ${room}`);
}
@SubscribeMessage('message')
sendToRoom(
@MessageBody() { room, msg }: { room: string; msg: string },
@ConnectedSocket() client: Socket,
) {
this.server.to(room).emit('message', { userId: client.id, msg });
}
}JWT validation happens in handleConnection before any message handler runs. @UseGuards(WsAuthGuard) adds a second layer on sensitive methods. Only room members receive messages sent with server.to(room).emit().
Horizontal scaling with Redis adapter
// main.ts
import { NestFactory } from '@nestjs/core';
import { createAdapter } from '@socket.io/redis-adapter';
import { AppModule } from './app.module';
import Redis from 'ioredis';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const pubClient = new Redis({ host: 'localhost', port: 6379 });
const subClient = pubClient.duplicate();
// sync broadcasts across all instances via Redis pub/sub
app.useWebSocketAdapter(createAdapter(pubClient, subClient));
await app.listen(3000);
}
bootstrap();With this setup, this.server.to('room').emit() in any gateway reaches clients on all instances. If Redis goes down, instances fall back to in-memory only - meaning pods stop sharing broadcasts but they do not crash.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.