Real-Time Communication: How WebSockets Power Live Features
HTTP requests are stateless — great for fetching data, not for reacting to events as they happen. WebSockets keep a persistent connection open, which is how we power live matchmaking, notifications, and presence indicators without constant polling.
NestJS provides a powerful decorator-based approach to WebSocket development. Gateways are the NestJS equivalent of controllers, but for WebSocket events. This guide covers building production-ready real-time features with NestJS and Socket.io — from basic gateways to authentication guards, validation pipes, interceptors, testing, and horizontal scaling with Redis.
Why NestJS for WebSockets?
While you can use raw Socket.io with Express, NestJS brings structure to real-time applications through its module system, dependency injection, and decorator patterns. The benefits become clear in larger projects:
- Decorator-based routing:
@SubscribeMessage()maps events to handlers, just like@Get()maps HTTP routes - Dependency injection: Services, guards, and pipes work identically to HTTP controllers
- Lifecycle hooks: Built-in interfaces for connection/disconnection management
- Module organization: Separate WebSocket features into focused modules
- Guards and pipes: Reuse existing authentication and validation logic
Architecture Overview
NestJS WebSocket Architecture
+-----------------------------------------------------------+
| NestJS Application |
| |
| +-------------------------------------------------------+ |
| | WebSocketModule | |
| | | |
| | +-----------+ +-----------+ +-----------+ | |
| | | ChatGW | | GameGW | | Notif.GW | | |
| | | /chat | | /game | | /notif | | |
| | +-----+-----+ +-----+-----+ +-----+-----+ | |
| | | | | | |
| | +---------------------------------------------------+ | |
| | | Shared Services | | |
| | | +-------------+ +----------------+ | | |
| | | |ConnectedUser| | MessageService | | | |
| | | +-------------+ +----------------+ | | |
| | +---------------------------------------------------+ | |
| | | |
| | +---------------------------------------------------+ | |
| | | Cross-Cutting Concerns | | |
| | | +--------+ +--------+ +------------+ | | |
| | | | WsGuar | | WsPipe | | WsIntrcept | | | |
| | | +--------+ +--------+ +------------+ | | |
| | +---------------------------------------------------+ | |
| +-------------------------------------------------------+ |
| | |
| Socket.io Server |
| | |
+-----------------------------------------------------------+
|
+------------+---------------+
| | |
+----+-----+ +---+------+ +------+------+
| Client 1 | | Client 2 | | Client N |
| (Browser)| | (Mobile) | | (Desktop) |
+----------+ +----------+ +-------------+
Installation
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
Core Decorators
| Decorator | Purpose |
|---|---|
| @WebSocketGateway() | Marks class as WebSocket gateway (port, namespace, cors) |
| @WebSocketServer() | Injects the Socket.io server instance |
| @SubscribeMessage() | Listens for specific client events |
| @MessageBody() | Extracts message payload from event |
| @ConnectedSocket() | Injects the client socket instance |
Basic Gateway
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
MessageBody,
ConnectedSocket,
OnGatewayInit,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
@WebSocketGateway({
namespace: 'chat',
cors: { origin: process.env.CLIENT_URL },
})
export class ChatGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server: Server;
private logger = new Logger('ChatGateway');
// Lifecycle: Server initialized
afterInit(server: Server) {
this.logger.log('WebSocket server initialized');
}
// Lifecycle: Client connected
handleConnection(client: Socket) {
this.logger.log(`Client connected: ${client.id}`);
}
// Lifecycle: Client disconnected
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
@SubscribeMessage('message')
handleMessage(
@MessageBody() data: { text: string; roomId: string },
@ConnectedSocket() client: Socket,
) {
// Broadcast to room
this.server.to(data.roomId).emit('message', {
userId: client.id,
text: data.text,
timestamp: Date.now(),
});
// Return acknowledgment
return { success: true };
}
}
Lifecycle Hooks
| Interface | Method | When Called |
|---|---|---|
| OnGatewayInit | afterInit(server) | After gateway is initialized |
| OnGatewayConnection | handleConnection(client) | When client connects |
| OnGatewayDisconnect | handleDisconnect(client) | When client disconnects |
Validation Pipes with DTOs
NestJS pipes work with WebSocket gateways just like with HTTP controllers. Use class-validator and class-transformer to validate incoming messages:
npm install class-validator class-transformer
import { IsString, IsNotEmpty, MaxLength, IsOptional } from 'class-validator';
// Define a DTO for chat messages
export class CreateMessageDto {
@IsString()
@IsNotEmpty()
@MaxLength(2000)
text: string;
@IsString()
@IsNotEmpty()
roomId: string;
@IsOptional()
@IsString()
replyToId?: string;
}
// Define a DTO for room operations
export class JoinRoomDto {
@IsString()
@IsNotEmpty()
roomId: string;
}
Apply the ValidationPipe globally or per-handler to automatically validate incoming WebSocket payloads:
import { UsePipes, ValidationPipe } from '@nestjs/common';
@WebSocketGateway({ namespace: 'chat' })
@UsePipes(new ValidationPipe({
transform: true, // Auto-transform payloads to DTO instances
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Throw on unknown properties
}))
export class ChatGateway {
@SubscribeMessage('message')
handleMessage(
@MessageBody() data: CreateMessageDto, // Validated automatically
@ConnectedSocket() client: Socket,
) {
// data.text is guaranteed to be a non-empty string, max 2000 chars
// data.roomId is guaranteed to be a non-empty string
this.server.to(data.roomId).emit('message', {
userId: client.userId,
text: data.text,
timestamp: Date.now(),
});
return { success: true };
}
@SubscribeMessage('joinRoom')
handleJoinRoom(
@MessageBody() data: JoinRoomDto,
@ConnectedSocket() client: Socket,
) {
client.join(data.roomId);
return { success: true, roomId: data.roomId };
}
}
Authentication Guard
Protect WebSocket connections with JWT authentication using guards. The guard runs before any message handler, just like HTTP guards:
import { CanActivate, ExecutionContext, Injectable, UseGuards } from '@nestjs/common';
import { WsException, WebSocketGateway, SubscribeMessage, ConnectedSocket } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import * as jwt from 'jsonwebtoken';
@Injectable()
export class WsJwtGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const client = context.switchToWs().getClient();
const token = client.handshake.auth.token;
if (!token) {
throw new WsException('Missing authentication token');
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
client.userId = decoded.userId;
return true;
} catch (err) {
throw new WsException('Invalid token');
}
}
}
// Apply guard to gateway
@WebSocketGateway()
@UseGuards(WsJwtGuard)
export class SecureGateway {
@SubscribeMessage('secure:action')
handleSecureAction(@ConnectedSocket() client: Socket) {
// client.userId is available from guard
return { userId: client.userId };
}
}
Interceptors for Logging and Metrics
Interceptors wrap around method execution, perfect for logging, performance tracking, and transforming responses:
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
UseInterceptors,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class WsLoggingInterceptor implements NestInterceptor {
private logger = new Logger('WebSocket');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const client = context.switchToWs().getClient();
const data = context.switchToWs().getData();
const handler = context.getHandler().name;
const now = Date.now();
this.logger.log(
`[IN] ${handler} from ${client.id} | payload: ${JSON.stringify(data)}`
);
return next.handle().pipe(
tap((response) => {
const duration = Date.now() - now;
this.logger.log(
`[OUT] ${handler} to ${client.id} | ${duration}ms | response: ${JSON.stringify(response)}`
);
}),
);
}
}
// Apply to a gateway
@WebSocketGateway({ namespace: 'chat' })
@UseInterceptors(WsLoggingInterceptor)
export class ChatGateway {
@SubscribeMessage('message')
handleMessage(@MessageBody() data: any) {
// Interceptor logs:
// [IN] handleMessage from abc123 | payload: {"text":"Hello"}
// [OUT] handleMessage to abc123 | 3ms | response: {"success":true}
return { success: true };
}
}
Multiple Namespaces Pattern
Separate concerns using different gateways for different features. Each namespace operates independently with its own middleware, guards, and event handlers:
// ─── Chat Namespace ──────────────────────────────────────
@WebSocketGateway({ namespace: 'chat' })
@UseGuards(WsJwtGuard)
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server;
constructor(
private connectedClients: ConnectedClientsService,
private messageService: MessageService,
) {}
handleConnection(client: Socket) {
this.connectedClients.addClient(client.id, client.userId);
}
handleDisconnect(client: Socket) {
this.connectedClients.removeClient(client.id);
}
@SubscribeMessage('message')
async handleMessage(@MessageBody() data: CreateMessageDto, @ConnectedSocket() client: Socket) {
const saved = await this.messageService.save({
text: data.text,
roomId: data.roomId,
userId: client.userId,
});
this.server.to(data.roomId).emit('message', saved);
return { success: true, messageId: saved.id };
}
@SubscribeMessage('typing')
handleTyping(@MessageBody() data: { roomId: string }, @ConnectedSocket() client: Socket) {
client.to(data.roomId).emit('typing', { userId: client.userId });
}
}
// ─── Game Namespace ──────────────────────────────────────
@WebSocketGateway({ namespace: 'game' })
@UseGuards(WsJwtGuard)
export class GameGateway {
@WebSocketServer() server: Server;
constructor(private gameService: GameService) {}
@SubscribeMessage('move')
async handleMove(
@MessageBody() data: { gameId: string; position: { x: number; y: number } },
@ConnectedSocket() client: Socket,
) {
const result = await this.gameService.processMove(data.gameId, client.userId, data.position);
// Broadcast updated game state to all players in the game room
this.server.to(`game:${data.gameId}`).emit('gameState', result);
return { success: true };
}
@SubscribeMessage('joinGame')
handleJoinGame(@MessageBody() data: { gameId: string }, @ConnectedSocket() client: Socket) {
client.join(`game:${data.gameId}`);
this.server.to(`game:${data.gameId}`).emit('playerJoined', { userId: client.userId });
}
}
// ─── Notifications Namespace ─────────────────────────────
@WebSocketGateway({ namespace: 'notifications' })
@UseGuards(WsJwtGuard)
export class NotificationsGateway {
@WebSocketServer() server: Server;
constructor(private connectedClients: ConnectedClientsService) {}
// Called by other services via dependency injection
sendNotification(userId: string, notification: any) {
this.connectedClients.sendToUser(this.server, userId, 'notification', notification);
}
@SubscribeMessage('markRead')
handleMarkRead(@MessageBody() data: { notificationId: string }) {
// Mark notification as read in database
return { success: true };
}
}
Connected Clients Service
Track connected users across namespaces for targeted messaging and presence detection:
@Injectable()
export class ConnectedClientsService {
// Map socketId -> userId
private clients = new Map<string, string>();
addClient(socketId: string, userId: string) {
this.clients.set(socketId, userId);
}
removeClient(socketId: string) {
this.clients.delete(socketId);
}
getSocketIdByUserId(userId: string): string | undefined {
for (const [socketId, id] of this.clients) {
if (id === userId) return socketId;
}
return undefined;
}
// Get all connected user IDs
getConnectedUserIds(): string[] {
return [...new Set(this.clients.values())];
}
// Check if a user is online
isUserOnline(userId: string): boolean {
return [...this.clients.values()].includes(userId);
}
// Send to specific user
sendToUser(server: Server, userId: string, event: string, data: any) {
const socketId = this.getSocketIdByUserId(userId);
if (socketId) {
server.to(socketId).emit(event, data);
}
}
// Send to multiple users
sendToUsers(server: Server, userIds: string[], event: string, data: any) {
for (const userId of userIds) {
this.sendToUser(server, userId, event, data);
}
}
}
Gateway Module Setup
Register gateways and services in a module. Each gateway is a provider, and shared services are exported for use by other modules:
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';
import { GameGateway } from './game.gateway';
import { NotificationsGateway } from './notifications.gateway';
import { ConnectedClientsService } from './connected-clients.service';
@Module({
providers: [
ChatGateway,
GameGateway,
NotificationsGateway,
ConnectedClientsService,
],
exports: [ConnectedClientsService, NotificationsGateway],
})
export class WebSocketModule {}
Exception Handling
Handle WebSocket exceptions with custom filters. Unlike HTTP exceptions, WebSocket errors are emitted back to the client socket:
import { Catch, ArgumentsHost } from '@nestjs/common';
import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets';
@Catch(WsException)
export class WsExceptionFilter extends BaseWsExceptionFilter {
catch(exception: WsException, host: ArgumentsHost) {
const client = host.switchToWs().getClient();
const error = exception.getError();
client.emit('error', {
status: 'error',
message: error,
timestamp: new Date().toISOString(),
});
}
}
// Catch ALL exceptions (not just WsException)
@Catch()
export class WsAllExceptionsFilter extends BaseWsExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const client = host.switchToWs().getClient();
const message = exception instanceof Error
? exception.message
: 'Internal server error';
client.emit('error', {
status: 'error',
message,
timestamp: new Date().toISOString(),
});
}
}
// Apply to gateway
@WebSocketGateway()
@UseFilters(new WsExceptionFilter())
export class ChatGateway {
@SubscribeMessage('message')
handleMessage(@MessageBody() data: any) {
if (!data.text) {
throw new WsException('Message text is required');
}
// Process message
}
}
Testing WebSocket Gateways
NestJS gateways can be tested using the standard @nestjs/testing module. Test both the gateway logic and the Socket.io integration:
Unit Testing
import { Test, TestingModule } from '@nestjs/testing';
import { ChatGateway } from './chat.gateway';
import { ConnectedClientsService } from './connected-clients.service';
describe('ChatGateway', () => {
let gateway: ChatGateway;
let connectedClients: ConnectedClientsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ChatGateway,
ConnectedClientsService,
],
}).compile();
gateway = module.get<ChatGateway>(ChatGateway);
connectedClients = module.get<ConnectedClientsService>(ConnectedClientsService);
});
it('should be defined', () => {
expect(gateway).toBeDefined();
});
it('should handle client connection', () => {
const mockClient = { id: 'socket-1', userId: 'user-1' } as any;
gateway.handleConnection(mockClient);
expect(connectedClients.isUserOnline('user-1')).toBe(true);
});
it('should handle client disconnection', () => {
const mockClient = { id: 'socket-1', userId: 'user-1' } as any;
gateway.handleConnection(mockClient);
gateway.handleDisconnect(mockClient);
expect(connectedClients.isUserOnline('user-1')).toBe(false);
});
it('should broadcast message to room', () => {
const mockEmit = jest.fn();
const mockTo = jest.fn(() => ({ emit: mockEmit }));
gateway.server = { to: mockTo } as any;
const data = { text: 'Hello', roomId: 'room-1' };
const client = { id: 'socket-1', userId: 'user-1' } as any;
const result = gateway.handleMessage(data as any, client);
expect(mockTo).toHaveBeenCalledWith('room-1');
expect(mockEmit).toHaveBeenCalledWith('message', expect.objectContaining({
text: 'Hello',
userId: 'user-1',
}));
expect(result).toEqual({ success: true, messageId: expect.any(String) });
});
});
Integration Testing with Socket.io Client
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { io, Socket as ClientSocket } from 'socket.io-client';
import { AppModule } from '../app.module';
describe('ChatGateway (Integration)', () => {
let app: INestApplication;
let clientSocket: ClientSocket;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
await app.listen(3001);
});
beforeEach((done) => {
clientSocket = io('http://localhost:3001/chat', {
auth: { token: 'test-jwt-token' },
});
clientSocket.on('connect', done);
});
afterEach(() => {
clientSocket.close();
});
afterAll(async () => {
await app.close();
});
it('should receive messages', (done) => {
clientSocket.on('message', (data) => {
expect(data.text).toBe('Hello World');
done();
});
clientSocket.emit('message', {
text: 'Hello World',
roomId: 'test-room',
});
});
it('should reject invalid messages', (done) => {
clientSocket.on('error', (error) => {
expect(error.message).toContain('text');
done();
});
// Send message without required 'text' field
clientSocket.emit('message', { roomId: 'test-room' });
});
});
Scaling with Redis Adapter
By default, Socket.io only works on a single server instance. For horizontal scaling across multiple servers, use the Redis adapter to synchronize events:
npm install @socket.io/redis-adapter redis
import { IoAdapter } from '@nestjs/platform-socket.io';
import { ServerOptions } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter>;
async connectToRedis(): Promise<void> {
const pubClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
});
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
this.adapterConstructor = createAdapter(pubClient, subClient);
}
createIOServer(port: number, options?: ServerOptions): any {
const server = super.createIOServer(port, options);
server.adapter(this.adapterConstructor);
return server;
}
}
// main.ts - Apply the adapter
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const redisIoAdapter = new RedisIoAdapter(app);
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);
await app.listen(3000);
}
Horizontal Scaling with Redis Adapter
+--------------+ +--------------+ +--------------+
| Server 1 | | Server 2 | | Server N |
| (NestJS) | | (NestJS) | | (NestJS) |
| Port 3000 | | Port 3000 | | Port 3000 |
+------+-------+ +------+-------+ +------+-------+
| | |
+---------------------+--------------------+
|
+------+------+
| Redis |
| Pub/Sub |
+-------------+
Events from any server are published to Redis
and delivered to clients on ALL servers.
Conclusion
NestJS WebSocket Gateways bring the full power of the NestJS ecosystem to real-time applications. By leveraging decorators, dependency injection, and the module system, you get the same structured development experience as HTTP controllers.
Key takeaways:
- Decorator-based routing: Map WebSocket events to handlers with
@SubscribeMessage() - Full DI support: Inject services, guards, pipes, and interceptors exactly like HTTP controllers
- Validation: Use
class-validatorDTOs withValidationPipefor type-safe message handling - Authentication: Protect gateways with JWT guards that run before message handlers
- Testability: Unit test gateways with mocks and integration test with real Socket.io clients
- Scalability: Use the Redis adapter to scale across multiple server instances
Explore the Code
Check out the ft_transcendence repository on GitHub to see these patterns in a real-world multiplayer game.
Written by

Technical Lead and Full Stack Engineer leading a 5-engineer team at Fygurs (Paris, Remote) on Azure cloud-native SaaS. Graduate of 1337 Coding School (42 Network / UM6P). Writes about architecture, cloud infrastructure, and engineering leadership.