A single Node.js server can handle 500,000+ idle WebSocket connections with proper tuning. But the moment you add a second server instance, you hit the fundamental WebSocket scaling problem: clients connected to Server 1 can't receive messages from Server 2 because their sockets live in different processes. Redis Pub/Sub solves this by acting as the message backplane between server instances — when any server needs to broadcast a message, it publishes to Redis, and all servers subscribe and fan out to their local connections. This guide covers the full implementation in NestJS with Socket.IO.
WebSockets are stateful — unlike HTTP requests, they maintain a persistent connection to a specific server instance. In a multi-server deployment behind a load balancer, a client is connected to exactly one server. When you want to send a message to a user who might be connected to any of your servers, you need a backplane that coordinates between servers. Without it, you'd need to send the message to every server and let each server fan out to clients — exactly what Redis Pub/Sub enables. The naive alternative — always routing the same user to the same server (sticky sessions) — works but fails when server instances restart or scale in.
Sticky sessions (also called session affinity) configure the load balancer to route the same client to the same server based on their IP or a cookie. This avoids the backplane requirement for direct client communication, but it doesn't solve the case where one server needs to send a message to a client on another server (which happens in any multi-user collaboration or broadcast scenario). Sticky sessions also break when a server restarts — clients must reconnect and may hit a different server, requiring state reconstruction. Use sticky sessions as a fallback during Redis failure, not as your primary scaling strategy.
WebSocket Horizontal Scaling with Redis Pub/Sub
WITHOUT Redis Adapter (BROKEN):
─────────────────────────────────────────────
User A (connected to Server 1)
User B (connected to Server 2)
Server 1 emits notification to User B → DROPPED
Server 1 doesn't know B's socket is on Server 2!
WITH Redis Pub/Sub Adapter (CORRECT):
─────────────────────────────────────────────
┌────────────────────────────────────────┐
│ Load Balancer (Nginx + ip_hash) │
│ sticky sessions as safety net │
└──────────┬──────────────┬─────────────┘
│ │
┌──────────▼──┐ ┌────▼────────────┐
│ NestJS │ │ NestJS │
│ Gateway 1 │ │ Gateway 2 │
│ User A ✓ │ │ User B ✓ │
└──────────┬──┘ └────┬────────────┘
│ subscribe │
└──────┬────────┘
│
┌────────▼────────┐
│ Redis │
│ Pub/Sub │ ← Backplane
│ Channel: │
│ socket.io#room │
└─────────────────┘
When Server 1 needs to notify User B (room: userId):
Server 1 → publish to Redis → Server 2 subscribes → fan-out to User B's socket ✓From building real-time notification features: use Socket.IO rooms to group clients by user ID. When you need to send a notification to a specific user, emit to their room (io.to(userId).emit(...)) rather than tracking socket IDs. The Redis adapter ensures that room membership is replicated across all server instances, so emitting to a room works regardless of which server the user's socket is on. This pattern handles multiple browser tabs from the same user correctly — all tabs join the same user room.
Socket.IO's @socket.io/redis-adapter package replaces the default in-memory adapter with a Redis-backed one. All server instances connect to the same Redis instance and subscribe to a shared channel. When Server 1 emits to a room or broadcasts, it publishes to Redis. All servers (including Server 1) receive the publication and fan out to locally connected clients in that room. The setup is a three-line change from the default Socket.IO server configuration — create a Redis publisher client, a subscriber client, and call adapter(createAdapter(pubClient, subClient)).
// main.ts — Redis adapter for Socket.IO
import { NestFactory } from '@nestjs/core';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const pubClient = createClient({ url: 'redis://redis:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
const io = app.get(Server); // get Socket.IO server instance
io.adapter(createAdapter(pubClient, subClient));
await app.listen(3000);
}
// notification.gateway.ts — NestJS WebSocket Gateway
@WebSocketGateway({ cors: { origin: '*' } })
export class NotificationGateway implements OnGatewayConnection {
@WebSocketServer() server: Server;
handleConnection(client: Socket) {
const userId = client.handshake.auth.userId;
// User joins their personal room — works across all server instances
client.join(userId);
}
// Called from any NestJS service (any server instance)
sendToUser(userId: string, event: string, data: unknown) {
// Redis adapter ensures this reaches the correct server
this.server.to(userId).emit(event, data);
}
// Called when invoice is approved — from the event handler
@OnEvent('invoice.approved')
async handleInvoiceApproved(payload: InvoiceApprovedEvent) {
this.sendToUser(payload.userId, 'notification', {
type: 'INVOICE_APPROVED',
invoiceId: payload.invoiceId,
message: 'Your invoice has been approved',
});
}
}In NestJS, WebSocket servers are implemented as Gateways — classes decorated with @WebSocketGateway. The gateway uses Socket.IO under the hood by default. To add the Redis adapter, override the afterInit hook in your gateway and set the adapter programmatically, or configure it at the application level in main.ts. The gateway can subscribe to application events (from NestJS EventEmitter or CQRS EventBus) and forward them to connected clients via socket emissions — this is how server-side events (invoice approved, document updated) reach the browser in real time.
Redis Pub/Sub is not magic. When you start broadcasting millions of messages per second across 100K+ sockets, Redis can become the bottleneck. Each message published to a channel is delivered to every subscriber (all your server instances) — the per-subscriber fan-out cost multiplies with server count. At very high scale, switch to Redis Cluster, use NATS JetStream (built for high-throughput pub/sub), or Kafka for the backplane. Also, Redis Pub/Sub is fire-and-forget — messages published while a subscriber is disconnected are lost. For reliable message delivery to WebSocket clients (notifications they must not miss), persist the notification in the database and deliver it on reconnect.
WebSocket connections drop — mobile users switch between WiFi and cellular, browser tabs sleep, servers restart. Build reconnection logic into your client. Socket.IO's client handles reconnection automatically with exponential backoff. On reconnect, the server should replay any missed messages. Implement a 'last seen' cursor: when a client reconnects, it sends its last received message timestamp, and the server queries missed notifications from the database and delivers them before resuming live events. Without this, reconnecting clients see a gap in real-time updates.
The deployment architecture for a horizontally scaled WebSocket server: load balancer (Nginx or cloud LB) → multiple NestJS Gateway instances → shared Redis Pub/Sub backplane. Configure the load balancer for sticky sessions as a safety net (in case the Redis adapter is temporarily unavailable). Run Redis in a High Availability configuration (Sentinel or Cluster) — if Redis goes down, all WebSocket communication breaks. Monitor Redis connection count, memory usage, and Pub/Sub message rate as leading indicators of backplane health. Scale NestJS instances horizontally when per-instance CPU or memory is consistently above 70%.
Redis Pub/Sub works well for moderate scale (tens of thousands of concurrent connections, millions of messages per day). For higher scale, consider: NATS (purpose-built messaging system with lower overhead than Redis for Pub/Sub, supports JetStream for persistent messaging), Kafka (for very high throughput with durable message history), or a managed WebSocket service like Ably or Pusher (trade operational control for managed scale). For an ERP or SaaS app with up to 10K concurrent users, Redis Pub/Sub is more than sufficient and avoids adding new infrastructure.