The challenge of real-time state across machines
When you move from a single NestJS server to multiple instances behind a load balancer, Socket.IO stops working the way you expect it to. A user connects to server A, sends a message, but the listener on server B never hears it. Scaling Socket.IO across instances requires deliberate architecture—specifically, a shared message broker that keeps all servers in sync. That's where the Redis adapter comes in, and it's the difference between a real-time feature that works in production and one that mysteriously breaks under load.
Why single-server Socket.IO breaks at scale
By default, Socket.IO stores rooms, subscriptions, and connection state in memory on each server. When you have two instances, they're completely isolated—each one thinks it's the only server running. If Client A connects to Server 1 and joins room "notifications", and Client B connects to Server 2 and joins the same room, a broadcast from Server 1 won't reach Client B because Server 2 has no idea Client B subscribed to anything.
This works fine when you have one server or when you're testing locally. But the moment you ship to production with multiple instances, real-time features silently fail for a percentage of your users. The Redis adapter solves this by moving that shared state off individual servers and into Redis, which all instances can read and write to.
Redis adapter syncs rooms and broadcasts
The Redis adapter uses Redis pub/sub channels and a data structure called an adapter to broadcast messages across all server instances. Here's how it works in practice:
import { WebSocketGateway, WebSocketServer, SubscribeMessage, MessageBody, ConnectedSocket } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
@WebSocketGateway({
namespace: 'chat',
cors: { origin: '*' }
})
export class ChatGateway {
@WebSocketServer()
server: Server;
constructor() {}
async afterInit(server: Server) {
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
server.adapter(createAdapter(pubClient, subClient));
console.log('Redis adapter initialized');
}
@SubscribeMessage('send-message')
async handleMessage(
@ConnectedSocket() client: Socket,
@MessageBody() data: { roomId: string; text: string }
) {
// This broadcasts to all instances in the room
this.server.to(data.roomId).emit('message-received', {
from: client.id,
text: data.text,
timestamp: new Date()
});
}
@SubscribeMessage('join-room')
handleJoinRoom(
@ConnectedSocket() client: Socket,
@MessageBody() data: { roomId: string }
) {
client.join(data.roomId);
this.server.to(data.roomId).emit('user-joined', {
userId: client.id,
count: this.server.sockets.adapter.rooms.get(data.roomId)?.size || 0
});
}
}When you initialize the Redis adapter, Socket.IO subscribes to Redis channels automatically. When a client on Server A publishes a message to room "sales-team", the adapter sends it through Redis pub/sub, and all instances (including Server B and Server C) receive it and emit it to their local clients in that room. This happens in milliseconds.
The adapter also maintains a Redis hash for each room, storing metadata about connections. When you query server.sockets.adapter.rooms.get('roomName').size, it's reading from that shared state, so all servers agree on the room occupancy.
Sticky sessions needed for polling fallback
Not all clients can use WebSockets. Some are behind corporate proxies, older browsers, or networks that block long-lived connections. Socket.IO has a fallback mechanism using HTTP long-polling. Here's where you run into a subtle problem: a polling request hits your load balancer, which might route it to a different server each time.
Without sticky sessions, Server A receives the connection handshake, generates a session ID, and stores it in memory. The next polling request goes to Server B, which has no idea who this client is, so it issues a new session ID. The client gets confused, and the connection drops.
Sticky sessions (also called session affinity) ensure that once a client is routed to a server, all subsequent requests from that client go to the same server. In nginx:
// nginx.conf example
upstream nestjs_backend {
hash $remote_addr consistent; // or ip_hash in older versions
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
server {
listen 80;
location / {
proxy_pass http://nestjs_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}With sticky sessions enabled, the first polling request from a client hashes the client's IP (or a cookie you set) to always route to the same backend instance. WebSocket clients don't need this because they upgrade on a single connection, but it's essential for polling users. Many production environments use IP-based hashing or distribute a load balancer cookie to handle this.
Watch Redis pub/sub throughput
The Redis adapter publishes a lot of messages. Every room subscription, every broadcast, every acknowledgment goes through Redis channels. If you have 10,000 concurrent connections across 5 servers, and users are sending 100 messages per second, that's potentially hundreds of thousands of Redis commands per second. Redis is fast, but it has limits.
Monitor these metrics in production:
- pubsub_channels — How many Redis pub/sub channels are active. If this grows unbounded, you have a leak.
- pubsub_patterns — Pattern subscriptions (less common but worth tracking).
- ops_per_sec — Redis commands per second. Typical load is under 50k/sec on a modern Redis instance, but spikes matter.
- client connections — How many active connections Redis has. With the adapter, each server instance opens at least 2 connections (pub and sub clients).
Set up monitoring with redis-cli and a monitoring tool like Datadog or Prometheus:
// Example: monitor Redis performance in your NestJS app
import { Injectable, OnModuleInit } from '@nestjs/common';
import { createClient } from 'redis';
@Injectable()
export class RedisMonitoringService implements OnModuleInit {
private client = createClient({ url: process.env.REDIS_URL });
async onModuleInit() {
await this.client.connect();
this.monitorRedisStats();
}
private async monitorRedisStats() {
setInterval(async () => {
const info = await this.client.info('stats');
const lines = info.split('\r\n');
const statsMap = {};
lines.forEach(line => {
const [key, value] = line.split(':');
if (key) statsMap[key] = value;
});
console.log('Redis ops/sec:', statsMap.instantaneous_ops_per_sec);
console.log('Total commands processed:', statsMap.total_commands_processed);
// Alert if throughput spikes
if (parseInt(statsMap.instantaneous_ops_per_sec) > 50000) {
console.warn('Redis throughput spike detected');
}
}, 10000); // Every 10 seconds
}
}If you see Redis becoming a bottleneck, consider clustering Redis (with Sentinel for failover), or using Redis Cluster to shard the load. But for most applications, a single beefy Redis instance with proper monitoring is sufficient.
Load test with thousands of connections
Before you ship a scaled Socket.IO setup to production, verify it actually works under realistic load. A tool like artillery or a custom load test with Node.js can simulate thousands of concurrent connections.
// load-test.js - simple load test with ws library
import WebSocket from 'ws';
const SERVER_URL = 'http://localhost:3000/socket.io/';
const NUM_CLIENTS = 5000;
const MESSAGES_PER_CLIENT = 10;
async function runLoadTest() {
const clients = [];
let connectedCount = 0;
let receivedCount = 0;
console.log(`Starting load test: ${NUM_CLIENTS} clients, ${MESSAGES_PER_CLIENT} messages each`);
for (let i = 0; i < NUM_CLIENTS; i++) {
const socket = require('socket.io-client')(`${SERVER_URL}`, {
reconnection: true,
reconnectionDelay: 100,
reconnectionDelayMax: 5000
});
socket.on('connect', () => {
connectedCount++;
if (connectedCount % 500 === 0) {
console.log(`Connected: ${connectedCount}/${NUM_CLIENTS}`);
}
socket.emit('join-room', { roomId: 'load-test-room' });
// Send messages
for (let j = 0; j < MESSAGES_PER_CLIENT; j++) {
setTimeout(() => {
socket.emit('send-message', {
roomId: 'load-test-room',
text: `Message ${j} from client ${i}`
});
}, j * 100);
}
});
socket.on('message-received', () => {
receivedCount++;
});
socket.on('disconnect', () => {
console.warn(`Client ${i} disconnected`);
});
socket.on('error', (err) => {
console.error(`Client ${i} error:`, err.message);
});
clients.push(socket);
}
// Report after 60 seconds
setTimeout(() => {
console.log(`\n=== Test Results ===`);
console.log(`Connected: ${connectedCount}`);
console.log(`Messages received: ${receivedCount}`);
console.log(`Success rate: ${((receivedCount / (NUM_CLIENTS * MESSAGES_PER_CLIENT)) * 100).toFixed(2)}%`);
process.exit(0);
}, 60000);
}
runLoadTest();
Run this against your multi-instance setup and watch for:
- Connection success rates (should be 99%+)
- Message delivery latency (median should be under 100ms for local Redis)
- Server CPU and memory usage (all instances should scale proportionally)
- Redis memory and command throughput (should stay stable)
If you see connection failures or message loss, it usually points to sticky sessions not working or Redis being overloaded. Fix those first before going live.
Common mistakes
- Forgetting sticky sessions and using polling — WebSocket clients don't need it, but polling clients will have a bad time. Always set sticky sessions on your load balancer.
- Not monitoring Redis — You won't know Redis is a bottleneck until your real-time features start degrading. Set up monitoring before issues happen.
- Using in-memory rooms alongside Redis — Some code stores room state in a local Map instead of relying on the Redis adapter. This causes race conditions across servers. Let the adapter handle all room logic.
- Assuming all clients use WebSocket — Many do not. If you don't configure polling properly with sticky sessions, certain users will experience unreliability.
- Testing only with one server — A feature works fine on localhost with one instance. Always test with at least two instances to catch synchronization bugs early.
Key takeaways
- The Redis adapter syncs room state and broadcasts across all server instances via pub/sub channels. Without it, messages never leave the server they arrive on.
- Sticky sessions are non-negotiable for HTTP long-polling clients. Configure your load balancer (nginx, AWS ALB, etc.) to route polling requests to the same instance.
- Monitor Redis ops/sec, pubsub_channels, and client connections. High throughput can become a bottleneck; catch it early with observability.
- Load test with realistic concurrency (thousands of connections) across multiple instances before shipping. A working single-server setup can break dramatically under multi-instance load without proper configuration.
- Keep rooms and subscriptions in Redis, never in local memory. The adapter handles this automatically when initialized correctly.






Comments
Loading comments…