Escalar conexiones WebSocket entre múltiples servidores
Cómo escalar conexiones WebSocket más allá de un servidor: sticky sessions, difusión pub/sub con Redis, estado de conexión y reconexión resiliente.

Un único servidor Node.js puede manejar cómodamente entre 50.000 y 100.000 conexiones WebSocket simultáneas. Pero cuando necesitas más capacidad — o cuando necesitas redundancia — añadir un segundo servidor lo rompe todo. El cliente A se conecta al servidor 1 y el cliente B se conecta al servidor 2. Cuando el cliente A envía un mensaje al cliente B, el servidor 1 no tiene forma de saber que esa conexión existe.
El problema de fondo es que las conexiones WebSocket son estado local del servidor: solo existen en el proceso que las aceptó. Tus mensajes necesitan una forma de cruzar esa frontera entre servidores. Esta guía recorre los patrones ya probados para escalar sistemas WebSocket de un solo servidor a muchos.
La línea base de un solo servidor
Antes de escalar, conviene entender cómo es una implementación de un solo servidor y en qué punto exacto se rompe.
import { WebSocketServer, WebSocket } from "ws";
// Single-server implementation — works fine until you add a second server
const connections = new Map<string, WebSocket>();
const rooms = new Map<string, Set<string>>();
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws, req) => {
const userId = authenticateConnection(req);
connections.set(userId, ws);
ws.on("message", (data) => {
const message = JSON.parse(data.toString());
switch (message.type) {
case "join_room":
joinRoom(userId, message.roomId);
break;
case "room_message":
broadcastToRoom(message.roomId, message.payload, userId);
break;
case "direct_message":
sendToUser(message.targetId, message.payload);
break;
}
});
ws.on("close", () => {
connections.delete(userId);
removeFromAllRooms(userId);
});
});
function sendToUser(targetId: string, payload: unknown): void {
const target = connections.get(targetId);
if (target?.readyState === WebSocket.OPEN) {
target.send(JSON.stringify(payload));
}
// ❌ If targetId is on another server, this silently fails
}
function broadcastToRoom(
roomId: string,
payload: unknown,
senderId: string
): void {
const members = rooms.get(roomId);
if (!members) return;
for (const memberId of members) {
if (memberId === senderId) continue;
sendToUser(memberId, payload);
}
// ❌ Only reaches members connected to THIS server
}Difusión pub/sub con Redis
La solución estándar es añadir una capa pub/sub. Cuando un servidor recibe un mensaje, lo publica en un canal de Redis. Todos los servidores están suscritos a ese canal y se encargan de entregarlo a sus conexiones locales.
import Redis from "ioredis";
import { WebSocketServer, WebSocket } from "ws";
class ScalableWebSocketServer {
private connections = new Map<string, WebSocket>();
private pub: Redis;
private sub: Redis;
private serverId: string;
constructor(port: number) {
this.serverId = `server-${port}-${Date.now()}`;
this.pub = new Redis(process.env.REDIS_URL);
this.sub = new Redis(process.env.REDIS_URL);
this.setupSubscriptions();
this.setupWebSocket(port);
}
private setupSubscriptions(): void {
this.sub.subscribe("ws:broadcast", "ws:direct", "ws:room");
this.sub.on("message", (channel, data) => {
const message = JSON.parse(data);
// Skip messages we published ourselves
if (message.sourceServer === this.serverId) return;
switch (channel) {
case "ws:direct":
this.deliverLocal(message.targetId, message.payload);
break;
case "ws:room":
this.deliverToLocalRoomMembers(
message.roomId,
message.payload,
message.senderId
);
break;
case "ws:broadcast":
this.deliverToAll(message.payload);
break;
}
});
}
sendToUser(targetId: string, payload: unknown): void {
// Try local delivery first
if (this.deliverLocal(targetId, payload)) return;
// Publish for other servers to deliver
this.pub.publish(
"ws:direct",
JSON.stringify({
sourceServer: this.serverId,
targetId,
payload,
})
);
}
broadcastToRoom(
roomId: string,
payload: unknown,
senderId: string
): void {
// Deliver to local members
this.deliverToLocalRoomMembers(roomId, payload, senderId);
// Publish for other servers
this.pub.publish(
"ws:room",
JSON.stringify({
sourceServer: this.serverId,
roomId,
payload,
senderId,
})
);
}
private deliverLocal(targetId: string, payload: unknown): boolean {
const ws = this.connections.get(targetId);
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
return true;
}
return false;
}
private deliverToLocalRoomMembers(
roomId: string,
payload: unknown,
senderId: string
): void {
// Room membership stored in Redis (shared across servers)
// Local delivery only for connections on this server
}
private deliverToAll(payload: unknown): void {
for (const ws of this.connections.values()) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
}
}Cómo almacenar el estado de las conexiones en Redis
La pertenencia a salas y la presencia de los usuarios deben compartirse entre servidores. Usa sets y hashes de Redis para llevar el registro de qué usuarios están en qué salas, y qué servidor mantiene cada conexión.
class ConnectionRegistry {
constructor(private redis: Redis, private serverId: string) {}
async registerConnection(userId: string): Promise<void> {
const pipeline = this.redis.pipeline();
// Track which server holds this connection
pipeline.hset("ws:connections", userId, this.serverId);
// Track all connections on this server (for cleanup on crash)
pipeline.sadd(`ws:server:${this.serverId}`, userId);
// Set presence with TTL (heartbeat will refresh)
pipeline.set(`ws:presence:${userId}`, "online", "EX", 60);
await pipeline.exec();
}
async removeConnection(userId: string): Promise<void> {
const pipeline = this.redis.pipeline();
pipeline.hdel("ws:connections", userId);
pipeline.srem(`ws:server:${this.serverId}`, userId);
pipeline.del(`ws:presence:${userId}`);
await pipeline.exec();
}
async joinRoom(userId: string, roomId: string): Promise<void> {
await this.redis.sadd(`ws:room:${roomId}`, userId);
}
async leaveRoom(userId: string, roomId: string): Promise<void> {
await this.redis.srem(`ws:room:${roomId}`, userId);
}
async getRoomMembers(roomId: string): Promise<string[]> {
return await this.redis.smembers(`ws:room:${roomId}`);
}
// Clean up stale connections when a server crashes
async cleanupServer(deadServerId: string): Promise<void> {
const users = await this.redis.smembers(
`ws:server:${deadServerId}`
);
const pipeline = this.redis.pipeline();
for (const userId of users) {
pipeline.hdel("ws:connections", userId);
pipeline.del(`ws:presence:${userId}`);
}
pipeline.del(`ws:server:${deadServerId}`);
await pipeline.exec();
}
}Reconexión del lado del cliente
Los clientes se desconectarán tarde o temprano — cambios de red, despliegues del servidor, timeouts del balanceador de carga. Un cliente robusto gestiona la reconexión de forma transparente, incluida la reproducción de los mensajes perdidos.
// ❌ Naive WebSocket client — no reconnection
const ws = new WebSocket("wss://api.example.com/ws");
ws.onmessage = (e) => handleMessage(JSON.parse(e.data));
// Connection drops → app is broken → user refreshes page
// ✅ Resilient WebSocket client with exponential backoff
class ResilientWebSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectDelay = 30_000;
private lastMessageId: string | null = null;
private messageHandlers: Array<(data: unknown) => void> = [];
constructor(private url: string) {
this.connect();
}
private connect(): void {
// Include last message ID for server to replay missed messages
const connectUrl = this.lastMessageId
? `${this.url}?after=${this.lastMessageId}`
: this.url;
this.ws = new WebSocket(connectUrl);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.id) this.lastMessageId = data.id;
this.messageHandlers.forEach((h) => h(data));
};
this.ws.onclose = (event) => {
if (event.code === 1000) return; // Normal close
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.ws?.close();
};
}
private scheduleReconnect(): void {
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts) +
Math.random() * 1000,
this.maxReconnectDelay
);
this.reconnectAttempts++;
setTimeout(() => this.connect(), delay);
}
onMessage(handler: (data: unknown) => void): void {
this.messageHandlers.push(handler);
}
send(data: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
}Balanceo de carga de conexiones WebSocket
Los balanceadores de carga HTTP deben configurarse para admitir WebSocket. La solicitud inicial de actualización (upgrade) HTTP establece la conexión, y los frames posteriores deben enrutarse siempre al mismo servidor.
# Nginx configuration for WebSocket load balancing
upstream websocket_servers {
# ip_hash ensures same client always hits same server
# Alternative: use a shared session store and round-robin
ip_hash;
server ws-server-1:8080;
server ws-server-2:8080;
server ws-server-3:8080;
}
server {
listen 443 ssl;
server_name ws.example.com;
location /ws {
proxy_pass http://websocket_servers;
# Required for WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
# Increase timeouts for long-lived connections
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
# Forward real client IP
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}# ❌ Default nginx config drops WebSocket after 60 seconds
# proxy_read_timeout default is 60s
# Long-lived WebSocket connections timeout silently
# ✅ Increase timeouts and configure health checks
# proxy_read_timeout 86400s (24 hours)
# Combine with application-level heartbeat every 30 seconds
# If no heartbeat response in 90 seconds, client reconnectsPuntos clave
- Las conexiones WebSocket son locales a cada servidor — los mensajes no pueden cruzar entre servidores sin una capa pub/sub; Redis pub/sub es la solución estándar
- Guarda la pertenencia a salas y la presencia en Redis — cada servidor necesita saber quién está en qué sala; los sets de Redis ofrecen comprobaciones de pertenencia O(1) compartidas entre todos los servidores
- Limpia las conexiones obsoletas cuando un servidor falla — registra qué conexiones pertenecen a qué servidor para que un verificador de estado (health checker) pueda limpiarlas cuando un servidor muere inesperadamente
- La reconexión del cliente debe ser automática — el backoff exponencial con jitter evita el efecto manada (thundering herd) al reiniciar un servidor; incluye el ID del último mensaje para reproducir los mensajes perdidos
- Configura los timeouts del balanceador de carga para conexiones de larga duración — los timeouts por defecto (60 s) matan las conexiones WebSocket en silencio; usa heartbeats a nivel de aplicación junto con timeouts de proxy más largos


