Patrones de arquitectura WebSocket para tiempo real escalable
Diseña arquitecturas WebSocket escalables: gestión de conexiones, pub/sub por salas, heartbeats, reconexión y escalado horizontal a miles.

El límite de un solo servidor
Las conexiones WebSocket son persistentes y con estado. Un único servidor que mantenga todas las conexiones funciona bien hasta que necesitas un segundo servidor. A partir de ahí, un mensaje enviado desde el Servidor A nunca llega a los clientes conectados al Servidor B. Escalar aplicaciones WebSocket exige decisiones arquitectónicas que las aplicaciones HTTP tradicionales nunca tienen que afrontar.
Gestión de conexiones
Cada conexión WebSocket necesita un seguimiento. Sin un registro, no puedes enviar mensajes dirigidos a usuarios, salas o canales específicos.
// ❌ Storing connections in a plain array — no way to target specific users
const connections: WebSocket[] = [];
// ✅ Connection registry with metadata for targeted messaging
interface ConnectionMeta {
userId: string;
rooms: Set<string>;
connectedAt: number;
lastPing: number;
}
class ConnectionRegistry {
private connections = new Map<string, WebSocket>();
private metadata = new Map<string, ConnectionMeta>();
add(connectionId: string, ws: WebSocket, userId: string): void {
this.connections.set(connectionId, ws);
this.metadata.set(connectionId, {
userId,
rooms: new Set(),
connectedAt: Date.now(),
lastPing: Date.now(),
});
}
remove(connectionId: string): void {
const meta = this.metadata.get(connectionId);
if (meta) {
for (const room of meta.rooms) {
this.leaveRoom(connectionId, room);
}
}
this.connections.delete(connectionId);
this.metadata.delete(connectionId);
}
getByUser(userId: string): WebSocket[] {
const sockets: WebSocket[] = [];
for (const [connId, meta] of this.metadata) {
if (meta.userId === userId) {
const ws = this.connections.get(connId);
if (ws) sockets.push(ws);
}
}
return sockets;
}
getByRoom(room: string): WebSocket[] {
const sockets: WebSocket[] = [];
for (const [connId, meta] of this.metadata) {
if (meta.rooms.has(room)) {
const ws = this.connections.get(connId);
if (ws) sockets.push(ws);
}
}
return sockets;
}
joinRoom(connectionId: string, room: string): void {
this.metadata.get(connectionId)?.rooms.add(room);
}
leaveRoom(connectionId: string, room: string): void {
this.metadata.get(connectionId)?.rooms.delete(room);
}
}Heartbeat y detección de conexiones muertas
TCP no siempre te avisa cuando una conexión se cae. Las redes móviles, cerrar la tapa del portátil y los cambios de red matan conexiones en silencio. Sin heartbeats, tu registro acumula conexiones muertas que desperdician memoria y provocan errores de envío.
class HeartbeatManager {
private intervals = new Map<string, NodeJS.Timeout>();
private readonly PING_INTERVAL = 30_000;
private readonly PONG_TIMEOUT = 10_000;
start(
connectionId: string,
ws: WebSocket,
onDead: (connectionId: string) => void
): void {
const interval = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) {
this.stop(connectionId);
onDead(connectionId);
return;
}
let pongReceived = false;
const onPong = () => {
pongReceived = true;
};
ws.once("pong", onPong);
ws.ping();
setTimeout(() => {
ws.removeListener("pong", onPong);
if (!pongReceived) {
ws.terminate();
this.stop(connectionId);
onDead(connectionId);
}
}, this.PONG_TIMEOUT);
}, this.PING_INTERVAL);
this.intervals.set(connectionId, interval);
}
stop(connectionId: string): void {
const interval = this.intervals.get(connectionId);
if (interval) {
clearInterval(interval);
this.intervals.delete(connectionId);
}
}
}Reconexión en el cliente
Los clientes deben manejar las desconexiones con elegancia. El backoff exponencial con jitter evita que miles de clientes intenten reconectarse al mismo tiempo tras un reinicio del servidor.
// ❌ Reconnect immediately in a tight loop — hammers the server
// ws.onclose = () => { connect(); };
// ✅ Exponential backoff with jitter
class ReconnectingWebSocket {
private ws: WebSocket | null = null;
private attempt = 0;
private readonly maxDelay = 30_000;
private readonly baseDelay = 1_000;
constructor(
private url: string,
private onMessage: (data: string) => void
) {
this.connect();
}
private connect(): void {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.attempt = 0; // Reset on successful connection
};
this.ws.onmessage = (event) => {
this.onMessage(event.data as string);
};
this.ws.onclose = () => {
this.scheduleReconnect();
};
}
private scheduleReconnect(): void {
const delay = Math.min(
this.baseDelay * Math.pow(2, this.attempt),
this.maxDelay
);
// Add jitter: random value between 0 and delay
const jitter = Math.random() * delay;
const finalDelay = delay + jitter;
this.attempt++;
setTimeout(() => this.connect(), finalDelay);
}
send(data: string): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(data);
}
}
close(): void {
this.attempt = Infinity; // Prevent reconnection
this.ws?.close();
}
}Escalado horizontal con pub/sub
Cuando añades un segundo servidor WebSocket, los mensajes de uno deben llegar también a los clientes conectados al otro. Redis Pub/Sub, o un message broker similar, cubre esa brecha.
import { createClient } from "redis";
class ScalableMessageBroker {
private publisher;
private subscriber;
private registry: ConnectionRegistry;
constructor(registry: ConnectionRegistry, redisUrl: string) {
this.registry = registry;
this.publisher = createClient({ url: redisUrl });
this.subscriber = createClient({ url: redisUrl });
}
async initialize(): Promise<void> {
await this.publisher.connect();
await this.subscriber.connect();
}
async subscribeToRoom(room: string): Promise<void> {
await this.subscriber.subscribe(`room:${room}`, (message) => {
// Deliver to local connections only
const sockets = this.registry.getByRoom(room);
for (const ws of sockets) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
}
});
}
async publishToRoom(room: string, message: string): Promise<void> {
// Publishes to ALL servers subscribed to this room
await this.publisher.publish(`room:${room}`, message);
}
async publishToUser(userId: string, message: string): Promise<void> {
await this.publisher.publish(`user:${userId}`, message);
}
}Diseño del protocolo de mensajes
Enviar cadenas de texto sin estructura por WebSocket se vuelve insostenible con el tiempo. Define un protocolo de mensajes tipado que soporte versionado y enrutamiento.
interface WsMessage<T = unknown> {
type: string;
payload: T;
timestamp: number;
correlationId?: string;
}
type MessageHandler<T = unknown> = (
connectionId: string,
payload: T
) => void | Promise<void>;
class MessageRouter {
private handlers = new Map<string, MessageHandler>();
on<T>(type: string, handler: MessageHandler<T>): void {
this.handlers.set(type, handler as MessageHandler);
}
async route(connectionId: string, raw: string): Promise<void> {
const message: WsMessage = JSON.parse(raw);
const handler = this.handlers.get(message.type);
if (!handler) {
console.warn(`No handler for message type: ${message.type}`);
return;
}
await handler(connectionId, message.payload);
}
}
// Usage
const router = new MessageRouter();
router.on<{ room: string }>("join_room", (connId, payload) => {
registry.joinRoom(connId, payload.room);
});
router.on<{ room: string; text: string }>(
"chat_message",
async (connId, payload) => {
const message = JSON.stringify({
type: "chat_message",
payload: { text: payload.text, from: connId },
timestamp: Date.now(),
});
await broker.publishToRoom(payload.room, message);
}
);Puntos clave
Escalar WebSocket es fundamentalmente distinto de escalar HTTP porque las conexiones son persistentes y con estado. Construye un registro de conexiones que rastree usuarios y salas desde el principio. Implementa heartbeats para detectar las conexiones muertas que TCP pierde en silencio.
La reconexión del cliente debe usar backoff exponencial con jitter: sin jitter, los reinicios del servidor desencadenan una estampida de reconexiones simultáneas. Para el escalado horizontal, usa Redis Pub/Sub o un message broker que conecte los mensajes entre las distintas instancias del servidor. Define un protocolo de mensajes tipado desde el principio; los mensajes de texto sin estructura se vuelven imposibles de mantener a medida que la aplicación crece. Cada decisión que aplazas —el seguimiento de conexiones, la limpieza de conexiones muertas, la mensajería entre servidores— es más difícil de incorporar después, una vez que ya tienes usuarios dependiendo del comportamiento de un solo servidor.


