Real-time features — live notifications, collaborative editing, status dashboards — are table stakes for modern web applications. NestJS's WebSocket Gateway abstraction makes building these features surprisingly clean. I've implemented real-time notification systems and live dashboard updates in production NestJS apps using Socket.io, and the NestJS abstraction handles 90% of the boilerplate you'd otherwise write yourself.
NestJS wraps Socket.io (or ws) in a Gateway decorator pattern. A Gateway is structurally identical to a Controller — it handles incoming events instead of HTTP requests. The @WebSocketGateway() decorator registers the class as a WebSocket server. @SubscribeMessage('event-name') handles specific events. @WebSocketServer() injects the underlying Socket.io server instance for broadcasting. The Gateway integrates fully with NestJS's dependency injection, so you can inject services, repositories, and config — exactly like a controller.
Rooms let you broadcast to a subset of connected clients — perfect for user-specific notifications or group chat. A client joins a room with socket.join('room-id'), and the server broadcasts to it with this.server.to('room-id').emit('event', data). Namespaces separate concerns at the connection level — a /notifications namespace and a /chat namespace are independent WebSocket endpoints. Use namespaces when different features need completely separate connection lifecycles and auth policies.
Single Instance (no Redis) Multi-Instance (with Redis)
─────────────────────────── ──────────────────────────────────
Client A ─── NestJS ──── Client B Client A ─── Instance 1 ─── Redis
│ │
Client B ─── Instance 2 ──────┘
│
Client C ─── Instance 3 ───────┘
All instances subscribe to Redis pub/sub
Any emit → Redis → all instances → target client
NestJS Gateway structure:
@WebSocketGateway({ namespace: '/notifications' })
export class NotificationsGateway implements OnGatewayConnection {
@WebSocketServer() server: Server
handleConnection(socket: Socket) {
const token = socket.handshake.auth.token
// verify JWT, associate socket with userId, join room
socket.join(`user:${userId}`)
}
async sendToUser(userId: string, event: string, data: unknown) {
this.server.to(`user:${userId}`).emit(event, data)
}
}From building a live order status dashboard in an ERP system: emit events only for data the client has permission to see. Use the connection handshake to associate the socket with a user ID and their accessible resource IDs, then on every emit, filter the target room by permission. Don't broadcast all order updates to all connected clients and filter on the frontend — that leaks business data to unauthorized users and wastes bandwidth.
A single NestJS instance keeps all socket connections in memory. When you scale to multiple instances behind a load balancer, socket connections are distributed across instances — instance A can't emit to a socket connected to instance B. The solution is Redis as a shared pub/sub broker. Install the socket.io-adapter-redis package, configure it with your Redis connection, and Socket.io handles cross-instance broadcasting automatically. Every instance publishes events to Redis, and Redis fans them out to all instances that have the target client.
# Install packages
npm install --save @nestjs/websockets @nestjs/platform-socket.io socket.io
npm install --save @socket.io/redis-adapter ioredis
# notifications.gateway.ts
import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'
import { Server, Socket } from 'socket.io'
import { JwtService } from '@nestjs/jwt'
@WebSocketGateway({
cors: { origin: process.env.FRONTEND_URL, credentials: true },
namespace: '/notifications',
})
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server
constructor(private jwtService: JwtService) {}
async handleConnection(socket: Socket) {
try {
const token = socket.handshake.auth.token
const payload = this.jwtService.verify(token)
socket.data.userId = payload.sub
await socket.join(`user:${payload.sub}`)
} catch {
socket.disconnect()
}
}
handleDisconnect(socket: Socket) {
// cleanup if needed
}
@SubscribeMessage('ping')
handlePing(socket: Socket) {
return { event: 'pong', data: { timestamp: Date.now() } }
}
// Called by other services to push notifications
async pushToUser(userId: string, notification: NotificationDto) {
this.server.to(`user:${userId}`).emit('notification', notification)
}
}
# main.ts — Redis adapter
import { createAdapter } from '@socket.io/redis-adapter'
import { createClient } from 'redis'
const pubClient = createClient({ url: process.env.REDIS_URL })
const subClient = pubClient.duplicate()
await Promise.all([pubClient.connect(), subClient.connect()])
app.getHttpServer().then((httpServer) => {
const io = require('socket.io')(httpServer)
io.adapter(createAdapter(pubClient, subClient))
})WebSocket connections don't carry headers after the initial HTTP upgrade. Pass JWT in the auth option of the Socket.io client handshake: io(url, { auth: { token: 'your-jwt' } }). On the NestJS Gateway side, implement a WebSocket middleware or use the handleConnection lifecycle hook to verify the token before allowing the connection. Reject invalid tokens by calling socket.disconnect(). This prevents unauthenticated sockets from listening to any events.
It's tempting to keep a Map<userId, socketId> in the Gateway class for direct targeting. This breaks the moment you have more than one server instance — instance A's map doesn't know about connections on instance B. Always use Redis or your database as the source of truth for socket-to-user mapping. Store the mapping in Redis on connection, clear it on disconnect, and look it up on every emit. This adds one Redis roundtrip per targeted emit but ensures correctness at any scale.
WebSocket connections drop — network interruptions, server restarts, mobile apps going to background. Socket.io's client handles reconnection automatically, but your Gateway needs to handle the reconnect event gracefully: re-join rooms, re-sync state. On the server side, implement the OnGatewayDisconnect interface to clean up when a client disconnects — remove from rooms, update presence status in the database, cancel any server-side timers tied to that client.
For any NestJS project needing real-time features, I use @nestjs/websockets with the Socket.io adapter. The NestJS Gateway abstraction is clean and integrates perfectly with the rest of the NestJS ecosystem. For production deployments with more than one instance, Redis pub/sub via @socket.io/redis-adapter is the standard solution. I don't use plain WebSocket (ws package) unless I need sub-millisecond latency and can't afford Socket.io's overhead — for ERP and SaaS apps, Socket.io's reconnection and room management features are worth the trade-off.
Before shipping WebSocket features to production: (1) Configure Redis adapter for multi-instance deployments. (2) Implement JWT auth in the connection handshake — no unauthenticated sockets. (3) Use rooms for permission-scoped broadcasts, not global events. (4) Handle disconnection and reconnection gracefully. (5) Set a maximum connection limit and circuit-break if Redis is unavailable — degrade gracefully to polling rather than crashing. (6) Monitor active socket connections in your observability stack — unexpected spikes indicate connection leaks.