Diseñar flujos de sincronización de datos bidireccionales y en tiempo real entre clientes y servidores requiere abandonar el paradigma clásico de solicitud-respuesta (Request-Response) en favor de conexiones persistentes de larga duración (Persistent Connections). En el ecosistema de Node.js, dos especificaciones fundamentales articulan estas cargas de trabajo: WebSocket (RFC 6455) para comunicación full-duplex y Server-Sent Events (SSE, Recomendación W3C) para transmisiones reactivas unidireccionales.
Comparación de la pila de red: WebSocket frente a Server-Sent Events (SSE):
1. WebSocket (RFC 6455) - Protocolo de trama binaria full-duplex:
Client ──[HTTP GET Upgrade: websocket]──> Server (Puerto 80/443)
Client <──[HTTP 101 Switching Protocols]── Server
==================================================================
Transmisión bidireccional de tramas (Flujo TCP):
Client ──[Opcode: Binary/Text, Masked Payload]──> Server
Client <──[Opcode: Binary/Text, Unmasked Payload]── Server
Client <──[Opcode: 0x9 Ping] / ──[Opcode: 0xA Pong]── Server
2. Server-Sent Events (SSE) - Flujo de texto unidireccional:
Client ──[HTTP GET (Accept: text/event-stream)]──> Server (Puerto 80/443)
Client <──[HTTP 200 OK (Transfer-Encoding: chunked)]── Server
==================================================================
Emisión unidireccional de eventos (Server -> Client):
Server ──[id: 101\nevent: delta\ndata: {"chunk":"Hello"}\n\n]──> ClientUna sesión WebSocket comienza como una negociación estándar de actualización de protocolo en HTTP/1.1: el protocolo de enlace HTTP Upgrade.
Connection: Upgrade, Upgrade: websocket, Sec-WebSocket-Version: 13 y una clave aleatoria criptográfica Sec-WebSocket-Key (16 bytes en Base64).258EAFA5-E914-47DA-95CA-C5AB0DC85B11), calcula su hash SHA-1, lo codifica en Base64 y lo devuelve en la cabecera Sec-WebSocket-Accept con el código HTTP 101 Switching Protocols.net.Socket pasa a operar directamente con tramas binarias de WebSocket.Cada trama de WebSocket contiene una cabecera de longitud variable de 2 a 14 bytes:
permessage-deflate).Server-Sent Events (SSE) opera directamente sobre HTTP convencional (HTTP/1.1 o HTTP/2) sin conmutación de protocolo:
Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive.\n\n. Se admiten campos de control: id: (identificador del mensaje), event: (nombre del evento personalizado), retry: (intervalo de reconexión en ms) y data: (contenido útil).EventSource del navegador gestiona la reconexión de forma automática, enviando la cabecera Last-Event-ID para reanudar el flujo sin pérdida de eventos.La ingeniería de infraestructuras en tiempo real resilientes requiere una gestión rigurosa del ciclo de vida de los sockets, detección activa de desconexiones en redes móviles y control de búferes de memoria.
HTTP Upgrade Socket Half-Open Mecanismo Heartbeat Contrapresión del socket (Socket Backpressure) Adaptador Pub/Sub distribuidoEscalabilidad horizontal de un clúster WebSocket tras un balanceador L7:
[ Balanceador de carga (AWS ALB / NGINX) ]
│ (Round-Robin / IP-Hash)
┌────────────────┴────────────────┐
▼ ▼
[Node.js Pod 1 (ws)] [Node.js Pod 2 (ws)]
│ │ │ │
Cliente A Cliente B Cliente C Cliente D
│ ▲
│ (Enviar mensaje a Cliente D) │
▼ │
[Pod 1 Publisher] [Pod 2 Subscriber]
│ ▲
└──────────► [ Clúster Redis ] ───┘
(Canal Pub/Sub: "chat:room_1")ws para transmitir decenas de miles de ticks binarios por segundo mediante Protobuf o MessagePack sin sobrecarga de cabeceras HTTP.La biblioteca ws constituye el estándar de producción en Node.js gracias a su rendimiento superior en el procesamiento de tramas con extensiones en C/C++ (bufferutil).
Para evitar fugas de memoria y el agotamiento de descriptores de archivo por conexiones inactivas (Sockets Half-Open), el servidor implementa un ciclo estricto de Heartbeat:
import { createServer, IncomingMessage } from "node:http";
import { WebSocketServer, WebSocket, RawData } from "ws";
// Extensión de la interfaz de WebSocket para seguimiento de estado
interface TrackedWebSocket extends WebSocket {
isAlive: boolean;
userId?: string;
subscribedChannels: Set<string>;
}
const server = createServer();
const wss = new WebSocketServer({
noServer: true, // Gestión manual de HTTP Upgrade para autenticación segura
maxPayload: 1024 * 1024, // Límite estricto de carga útil: 1 MB
});
// 1. Gestión segura del protocolo de enlace HTTP Upgrade
server.on("upgrade", (request: IncomingMessage, socket, head) => {
const url = new URL(request.url ?? "", `http://${request.headers.host}`);
const token = url.searchParams.get("token");
// Validación de autenticación previa a la concesión de cambio de protocolo
if (!token || token !== "valid-production-token") {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
const trackedWs = ws as TrackedWebSocket;
trackedWs.isAlive = true;
trackedWs.userId = "usr_" + Math.random().toString(36).substring(2, 9);
trackedWs.subscribedChannels = new Set();
wss.emit("connection", trackedWs, request);
});
});
// 2. Gestión de conexiones activas
wss.on("connection", (ws: TrackedWebSocket) => {
console.log(`[WS] Cliente conectado: ${ws.userId}`);
// Restablecimiento del estado activo al recibir trama Pong
ws.on("pong", () => {
ws.isAlive = true;
});
ws.on("message", (data: RawData, isBinary: boolean) => {
try {
if (isBinary) {
// Procesamiento de tramas binarias (Buffer)
console.log(`[WS Binary] Recibidos ${data.length} bytes`);
return;
}
const message = JSON.parse(data.toString());
handleClientMessage(ws, message);
} catch (err) {
console.error("[WS] Formato de mensaje incorrecto:", err);
ws.send(JSON.stringify({ error: "Invalid JSON format" }));
}
});
ws.on("close", (code, reason) => {
console.log(`[WS] Cliente ${ws.userId} desconectado (Código: ${code}, Causa: ${reason.toString()})`);
});
ws.on("error", (error) => {
console.error(`[WS Error ${ws.userId}]:`, error);
});
});
function handleClientMessage(ws: TrackedWebSocket, payload: Record<string, unknown>) {
if (payload.action === "subscribe" && typeof payload.channel === "string") {
ws.subscribedChannels.add(payload.channel);
ws.send(JSON.stringify({ status: "subscribed", channel: payload.channel }));
}
}
// 3. Mecanismo de Heartbeat para detectar sockets Half-Open
// Verificación periódica cada 30 segundos
const HEARTBEAT_INTERVAL = 30000;
const interval = setInterval(() => {
for (const client of wss.clients) {
const ws = client as TrackedWebSocket;
if (!ws.isAlive) {
console.warn(`[WS Heartbeat] El socket ${ws.userId} no respondió a Ping. Forzando cierre.`);
// terminate() interrumpe el socket emitiendo TCP RST a nivel de kernel
ws.terminate();
continue;
}
// Marcamos inactivo y emitimos trama de control Ping (Opcode: 0x9)
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_INTERVAL);
wss.on("close", () => {
clearInterval(interval);
});
server.listen(8080, () => {
console.log("[WS Server] Servidor en ejecución en el puerto 8080");
});La acumulación descontrolada en el búfer de envío del socket (socket.bufferedAmount) provoca frecuentes caídas del proceso por agotamiento de memoria (OOM). Si un cliente utiliza una conexión móvil lenta y el servidor emite flujos continuos, el kernel del SO no puede desalojar los paquetes de inmediato.
import { WebSocket } from "ws";
// Umbral de seguridad del búfer de salida (ej. 512 KB)
const HIGH_WATERMARK = 512 * 1024;
/**
* Emite datos respetando la contrapresión del socket del cliente
*/
export async function sendWithBackpressure(
ws: WebSocket,
data: string | Buffer
): Promise<boolean> {
if (ws.readyState !== WebSocket.OPEN) {
return false;
}
// Comprobar si el búfer ha rebasado el umbral seguro
if (ws.bufferedAmount >= HIGH_WATERMARK) {
console.warn(`[Backpressure Alert] Búfer saturado: ${ws.bufferedAmount} bytes`);
// Conceder tiempo al cliente para vaciar su cola de recepción
const isDrained = await waitForDrain(ws, 5000);
if (!isDrained) {
console.error("[Backpressure Drop] El cliente no procesa los datos. Forzando cierre.");
ws.terminate();
return false;
}
}
ws.send(data);
return true;
}
function waitForDrain(ws: WebSocket, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
const startTime = Date.now();
const checkInterval = setInterval(() => {
// Si el cliente interrumpió la conexión durante la espera
if (ws.readyState !== WebSocket.OPEN) {
clearInterval(checkInterval);
resolve(false);
return;
}
// El búfer se ha desalojado por debajo de la mitad del umbral
if (ws.bufferedAmount < HIGH_WATERMARK / 2) {
clearInterval(checkInterval);
resolve(true);
return;
}
// Se superó el límite de espera máximo
if (Date.now() - startTime > timeoutMs) {
clearInterval(checkInterval);
resolve(false);
}
}, 50);
});
}import Fastify, { FastifyRequest, FastifyReply } from "fastify";
const app = Fastify();
interface QueryParams {
prompt: string;
}
app.get(
"/api/v1/ai/completions/stream",
async (request: FastifyRequest<{ Querystring: QueryParams }>, reply: FastifyReply) => {
const { prompt } = request.query;
if (!prompt) {
reply.status(400).send({ error: "El parámetro prompt es obligatorio" });
return;
}
// 1. Establecer cabeceras obligatorias para Server-Sent Events
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", // Clave en NGINX: inhabilita el almacenamiento intermedio del proxy
});
// 2. Establecer tiempo de reintento de reconexión del cliente
reply.raw.write("retry: 3000\n\n");
let eventId = 0;
const tokens = ["Arquitectura", " ", "en", " ", "tiempo", " ", "real", " ", "con", " ", "Node.js."];
// 3. Emisión periódica de fragmentos
const streamInterval = setInterval(() => {
if (eventId >= tokens.length) {
// Enviar evento de finalización
reply.raw.write(`id: ${eventId}\nevent: done\ndata: [DONE]\n\n`);
clearInterval(streamInterval);
reply.raw.end();
return;
}
const token = tokens[eventId];
eventId++;
const payload = JSON.stringify({ token, index: eventId });
// Formateo según la especificación SSE: id, event, data
reply.raw.write(`id: ${eventId}\nevent: token\ndata: ${payload}\n\n`);
}, 150);
// 4. Liberar recursos al cerrar la pestaña o desconectar el cliente
request.raw.on("close", () => {
clearInterval(streamInterval);
console.log("[SSE] El cliente interrumpió la conexión del flujo");
});
}
);
app.listen({ port: 3001, host: "0.0.0.0" }, () => {
console.log("[SSE Service] Servidor de flujo activo en el puerto 3001");
});Al distribuir el servicio entre múltiples pods de Kubernetes tras un balanceador, los usuarios de una misma sala pueden estar conectados a servidores físicos diferentes. Un bus de mensajes distribuido coordina el enrutamiento.
El Cliente A transmite un mensaje a su socket local en el Pod 1.
El Pod 1 serializa el mensaje y lo emite en el canal de Redis 'channel:room_42'.
Redis reparte el mensaje a todos los pods suscritos a ese canal (Pod 1, Pod 2, Pod N).
El Pod 2 recibe la notificación de Redis y la reparte a los sockets locales correspondientes (Cliente B).
import { WebSocketServer, WebSocket } from "ws";
import Redis from "ioredis";
export interface BroadcastMessage {
channel: string;
senderId: string;
event: string;
data: unknown;
}
export class DistributedWebSocketBroker {
private publisher: Redis;
private subscriber: Redis;
private wss: WebSocketServer;
private localSubscriptions: Map<string, Set<WebSocket>> = new Map();
constructor(wss: WebSocketServer, redisUri: string) {
this.wss = wss;
// En Redis, una conexión en modo suscripción (SUBSCRIBE) no puede emitir
// comandos regulares; es indispensable contar con dos conexiones independientes.
this.publisher = new Redis(redisUri, { lazyConnect: false });
this.subscriber = new Redis(redisUri, { lazyConnect: false });
this.initRedisSubscriber();
}
private initRedisSubscriber() {
this.subscriber.on("message", (channel: string, messageStr: string) => {
try {
const parsed: BroadcastMessage = JSON.parse(messageStr);
this.broadcastLocal(channel, parsed);
} catch (err) {
console.error("[Redis Sub Error] Error al analizar el mensaje distribuido:", err);
}
});
}
public subscribeLocal(channel: string, ws: WebSocket) {
if (!this.localSubscriptions.has(channel)) {
this.localSubscriptions.set(channel, new Set());
// Suscribir el nodo del servidor al canal de Redis
this.subscriber.subscribe(channel);
}
this.localSubscriptions.get(channel)!.add(ws);
}
public unsubscribeLocal(channel: string, ws: WebSocket) {
const clients = this.localSubscriptions.get(channel);
if (!clients) return;
clients.delete(ws);
if (clients.size === 0) {
this.localSubscriptions.delete(channel);
// Cancelar suscripción en Redis si no quedan oyentes locales
this.subscriber.unsubscribe(channel);
}
}
public async publishToCluster(message: BroadcastMessage): Promise<void> {
const payload = JSON.stringify(message);
await this.publisher.publish(message.channel, payload);
}
private broadcastLocal(channel: string, message: BroadcastMessage) {
const clients = this.localSubscriptions.get(channel);
if (!clients) return;
const payload = JSON.stringify(message);
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(payload);
}
}
}
public async destroy(): Promise<void> {
await this.subscriber.quit();
await this.publisher.quit();
}
}Confiar únicamente en el evento ws.on('close') impide detectar caídas inesperadas de red en dispositivos móviles. Sin una señal TCP FIN, el socket permanece indefinidamente en memoria. El barrido regular con ws.ping() y la terminación forzosa con ws.terminate() de sockets que no responden es imperativo.
Efectuar difusiones masivas mediante for (const client of clients) client.send(data) sin evaluar client.bufferedAmount. Ante clientes conectados mediante redes móviles congestionadas, la memoria de Node.js acumula paquetes sin emitir hasta provocar la caída del proceso por falta de memoria (OOM).
Utilizar la extensión permessage-deflate en ws comprime cada trama pequeña mediante zlib. Superando los 10 000 mensajes por segundo, el bucle de eventos monohilo de Node.js se bloquea por la carga de compresión en CPU, multiplicando las latencias p99.
Desplegar servidores SSE tras NGINX sin inhabilitar el búfer del proxy (proxy_buffering off; o cabecera X-Accel-Buffering: no). NGINX retiene los fragmentos hasta llenar su búfer de 4 a 16 KB, entregando los tokens en bloques demorados en lugar de un flujo continuo.
El cliente solicita Upgrade: websocket con la clave Sec-WebSocket-Key. El servidor valida la autenticación.
El servidor envía el hash en Sec-WebSocket-Accept. El socket conmuta al protocolo binario de tramas.
Comunicación tipada bidireccional continua. Las tramas del cliente se desenmascaran con claves XOR de 4 bytes.
El servidor emite tramas Ping cada 30s. Las conexiones sin respuesta Pong se liberan con terminate().
Las partes intercambian tramas de cierre Opcode 0x8 con código de estado antes de desmontar el enlace TCP.