En las aplicaciones web interactivas modernas, el modelo clásico cliente-servidor basado en sondeos periódicos (Short Polling o Long Polling) no cumple con los requisitos rigurosos de latencia (Latency) y consumo de red:
Para transferir datos en streaming de alta velocidad y baja latencia, se utilizan tres protocolos de red fundamentales:
En aplicaciones React, el desafío arquitectónico principal consiste en integrar sockets de red persistentes con el ciclo de vida declarativo de los componentes: prevenir fugas de memoria, gestionar montajes y desmontajes bajo el StrictMode de React 19, erradicar sockets zombi (conexiones semiabiertas) y garantizar la entrega ordenada de mensajes ante fluctuaciones de red.
Un socket de red es un recurso externo imperativo cuyo ciclo de vida no depende de los ciclos de renderizado de React. Instanciar new WebSocket() directamente en el cuerpo de un componente provoca la apertura descontrolada de cientos de conexiones paralelas con cada cambio de estado. La gestión del socket debe encapsularse en un hook aislado o servicio singleton con una máquina de estados finitos, una cola de mensajes offline acotada y un algoritmo de reconexión con Full Jitter para no sobrecargar el backend.
Una conexión de red en React debe concebirse como un recurso compartido externo gobernado por una máquina de estados finitos (State Machine): CONNECTING, OPEN, CLOSING, CLOSED.
┌────────────────────────────────────────────────────────────────────────┐
│ PROTOCOLOS DE TRANSMISIÓN DE DATOS EN REAL-TIME │
│ │
│ 1. WebSockets (TCP): │
│ Client <══════════════ Intercambio bidireccional (JSON/Binary) ═════> Server│
│ • Pros: Mínimo overhead de tramas (2-10 bytes), dúplex completo │
│ • Contras: Head-of-Line Blocking a nivel TCP si se pierden paquetes │
│ │
│ 2. Server-Sent Events (HTTP/2 o HTTP/3): │
│ Client <────────────────── Stream de eventos (text/event-stream) ─── Server│
│ Client ─── HTTP POST / RPC ───> (Canal dedicado para órdenes) │
│ • Pros: Reconexión nativa automática, multiplexación en HTTP/2 │
│ • Contras: Solo texto UTF-8, estrictamente unidireccional │
│ │
│ 3. WebTransport (QUIC / UDP): │
│ Client <─ ─ ─ ─ ─ Datagramas no fiables (UDP) ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─> Server│
│ Client <═══════ Streams multiplexados y fiables ════════════════════> Server│
│ • Pros: Cero Head-of-Line Blocking entre streams, latencia ultra │
│ baja para vídeo, audio, telemetría y videojuegos │
└────────────────────────────────────────────────────────────────────────┘Para una resistencia de nivel de producción, la arquitectura del hook useWebSocket se fundamenta en tres invariantes del sistema:
OPEN.Creación de la instancia WebSocket en una referencia aislada, enlace de listeners tipados e inicio del temporizador Heartbeat.
Envío de tramas ping cada 30 segundos; si el servidor no responde con un pong en 5 segundos, se fuerza el cierre del socket.
Las llamadas a sendMessage durante pérdidas de red se almacenan en un búfer circular restringido por maxQueueSize.
Cálculo de un retardo aleatorizado para distribuir de manera homogénea la carga sobre los gateways en proceso de recuperación.
Al pasar a estado OPEN, se reinicia el contador de intentos y se transmiten en ráfaga los mensajes pendientes.
¿En qué escenario está arquitectónicamente justificado elegir Server-Sent Events (SSE) frente a WebSockets full-duplex?
ArrayBuffer para evitar pausas del Garbage Collector de V8.A continuación se detalla la implementación completa sin recurrir a tipos any, con soporte para tipado estricto genérico, algoritmo Full Jitter, Heartbeat Ping-Pong y cola de salida offline:
'use client';
import * as React from 'react';
export type WebSocketStatus = 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED';
export interface UseWebSocketOptions<TIncoming, TOutgoing> {
url: string;
protocols?: string | string[];
/** Retardo base de reconexión en ms (por defecto 1000) */
baseReconnectDelayMs?: number;
/** Retardo máximo de reconexión en ms (por defecto 30000) */
maxReconnectDelayMs?: number;
/** Intervalo de envío de ping Heartbeat en ms (por defecto 30000) */
heartbeatIntervalMs?: number;
/** Tiempo de espera de respuesta Heartbeat en ms (por defecto 5000) */
heartbeatTimeoutMs?: number;
/** Mensaje de ping a transmitir (por defecto { type: 'ping' }) */
pingMessage?: TOutgoing;
/** Capacidad máxima de la cola offline de mensajes */
maxQueueSize?: number;
/** Conexión automática al montar el componente */
autoConnect?: boolean;
onMessage?: (message: TIncoming) => void;
onError?: (event: Event) => void;
onOpen?: (event: Event) => void;
onClose?: (event: CloseEvent) => void;
}
export interface UseWebSocketReturn<TIncoming, TOutgoing> {
status: WebSocketStatus;
sendMessage: (data: TOutgoing) => boolean;
lastMessage: TIncoming | null;
connect: () => void;
disconnect: () => void;
}
export function useWebSocket<TIncoming = unknown, TOutgoing = unknown>(
options: UseWebSocketOptions<TIncoming, TOutgoing>
): UseWebSocketReturn<TIncoming, TOutgoing> {
const {
url,
protocols,
baseReconnectDelayMs = 1000,
maxReconnectDelayMs = 30000,
heartbeatIntervalMs = 30000,
heartbeatTimeoutMs = 5000,
pingMessage = { type: 'ping' } as unknown as TOutgoing,
maxQueueSize = 100,
autoConnect = true,
onMessage,
onError,
onOpen,
onClose,
} = options;
const [status, setStatus] = React.useState<WebSocketStatus>('CLOSED');
const [lastMessage, setLastMessage] = React.useState<TIncoming | null>(null);
// Referencias para aislar la infraestructura de red de los re-renders de React
const wsRef = React.useRef<WebSocket | null>(null);
const reconnectAttemptsRef = React.useRef<number>(0);
const reconnectTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const heartbeatTimerRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
const heartbeatTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const messageQueueRef = React.useRef<string[]>([]);
const isManuallyClosedRef = React.useRef<boolean>(false);
// Almacenamiento de referencias estables a los callbacks
const callbacksRef = React.useRef({ onMessage, onError, onOpen, onClose });
React.useEffect(() => {
callbacksRef.current = { onMessage, onError, onOpen, onClose };
});
const clearTimers = React.useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (heartbeatTimerRef.current) {
clearInterval(heartbeatTimerRef.current);
heartbeatTimerRef.current = null;
}
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
heartbeatTimeoutRef.current = null;
}
}, []);
// Cálculo de retardo con la fórmula Full Jitter
const calculateBackoff = React.useCallback(
(attempt: number): number => {
const exponentialDelay = baseReconnectDelayMs * Math.pow(2, attempt);
const cappedDelay = Math.min(maxReconnectDelayMs, exponentialDelay);
return Math.floor(Math.random() * cappedDelay);
},
[baseReconnectDelayMs, maxReconnectDelayMs]
);
// Vaciado de los mensajes almacenados en la cola hacia el socket abierto
const flushQueue = React.useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
while (messageQueueRef.current.length > 0) {
const payload = messageQueueRef.current.shift();
if (payload) {
wsRef.current.send(payload);
}
}
}, []);
// Monitorización de vitalidad del socket mediante Ping-Pong
const startHeartbeat = React.useCallback(() => {
if (heartbeatTimerRef.current) clearInterval(heartbeatTimerRef.current);
heartbeatTimerRef.current = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(pingMessage));
// Si no se recibe respuesta antes de heartbeatTimeoutMs, el socket está bloqueado
heartbeatTimeoutRef.current = setTimeout(() => {
console.warn('[WebSocket]: Timeout de Heartbeat excedido. Forzando cierre de socket zombi...');
wsRef.current?.close(4000, 'Heartbeat Timeout');
}, heartbeatTimeoutMs);
}
}, heartbeatIntervalMs);
}, [heartbeatIntervalMs, heartbeatTimeoutMs, pingMessage]);
const connect = React.useCallback(() => {
clearTimers();
isManuallyClosedRef.current = false;
try {
setStatus('CONNECTING');
const ws = new WebSocket(url, protocols);
wsRef.current = ws;
ws.onopen = (event) => {
setStatus('OPEN');
reconnectAttemptsRef.current = 0;
startHeartbeat();
flushQueue();
callbacksRef.current.onOpen?.(event);
};
ws.onmessage = (event: MessageEvent<string>) => {
// Cualquier paquete entrante resetea el temporizador de fallo del Heartbeat
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
heartbeatTimeoutRef.current = null;
}
try {
const parsedData = JSON.parse(event.data) as TIncoming;
// Ignorar acuses de recibo de ping técnicos
if ((parsedData as { type?: string })?.type === 'pong') return;
setLastMessage(parsedData);
callbacksRef.current.onMessage?.(parsedData);
} catch {
setLastMessage(event.data as unknown as TIncoming);
callbacksRef.current.onMessage?.(event.data as unknown as TIncoming);
}
};
ws.onerror = (event) => {
callbacksRef.current.onError?.(event);
};
ws.onclose = (event) => {
clearTimers();
setStatus('CLOSED');
wsRef.current = null;
callbacksRef.current.onClose?.(event);
if (!isManuallyClosedRef.current) {
const delay = calculateBackoff(reconnectAttemptsRef.current);
reconnectAttemptsRef.current += 1;
reconnectTimerRef.current = setTimeout(() => {
connect();
}, delay);
}
};
} catch {
setStatus('CLOSED');
}
}, [url, protocols, clearTimers, startHeartbeat, flushQueue, calculateBackoff]);
const disconnect = React.useCallback(() => {
isManuallyClosedRef.current = true;
clearTimers();
if (wsRef.current) {
setStatus('CLOSING');
wsRef.current.close(1000, 'User Disconnect');
wsRef.current = null;
}
setStatus('CLOSED');
}, [clearTimers]);
const sendMessage = React.useCallback(
(data: TOutgoing): boolean => {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(payload);
return true;
}
// Guardar en la cola offline con desalojo del elemento más antiguo si se supera el límite
if (messageQueueRef.current.length >= maxQueueSize) {
messageQueueRef.current.shift();
}
messageQueueRef.current.push(payload);
return false;
},
[maxQueueSize]
);
React.useEffect(() => {
if (autoConnect) {
connect();
}
return () => {
isManuallyClosedRef.current = true;
clearTimers();
if (wsRef.current) {
wsRef.current.close();
}
};
}, [autoConnect, connect, clearTimers]);
return {
status,
sendMessage,
lastMessage,
connect,
disconnect,
};
}Creación de la instancia WebSocket, configuración de listeners tipados y activación del loop de Heartbeat.
Envío regular de pings. Si no hay respuesta pong en 5 segundos, se desconecta el socket zombi.
Los mensajes enviados sin conexión entran en una cola FIFO acotada por maxQueueSize.
Cálculo de intervalo aleatorizado para proteger la infraestructura frente a ondas de Thundering Herd.
¿Por qué el algoritmo estándar de Exponential Backoff (sin dispersión aleatoria o jitter) puede derribar un servidor tras una caída masiva de red?
A continuación se muestra un componente de visualización de precios y operaciones financieras en tiempo real con useWebSocket, tipado riguroso, indicador visual de estado y limitador de historial de cotizaciones:
'use client';
import * as React from 'react';
import { useWebSocket, type WebSocketStatus } from '@/shared/hooks/useWebSocket';
interface PriceTick {
symbol: string;
price: number;
change24h: number;
timestamp: number;
}
interface SubscribeCommand {
action: 'subscribe' | 'unsubscribe';
channel: string;
}
const statusColorMap: Record<WebSocketStatus, string> = {
CONNECTING: 'bg-yellow-500',
OPEN: 'bg-emerald-500',
CLOSING: 'bg-orange-500',
CLOSED: 'bg-red-500',
};
export function CryptoTicker({ symbol }: { symbol: string }) {
const [ticks, setTicks] = React.useState<PriceTick[]>([]);
const { status, sendMessage, lastMessage } = useWebSocket<PriceTick, SubscribeCommand>({
url: 'wss://stream.agenticlab.dev/realtime',
autoConnect: true,
onOpen: () => {
// Suscribirse al canal del símbolo de inmediato al abrir la conexión
sendMessage({ action: 'subscribe', channel: `quotes:${symbol}` });
},
onMessage: (data) => {
if (data && data.symbol === symbol) {
setTicks((prev) => [data, ...prev.slice(0, 19)]);
}
},
});
return (
<div className="p-6 rounded-2xl bg-bg-surface border border-border space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${statusColorMap[status]} animate-pulse`} />
<h3 className="font-semibold text-text-primary text-sm tracking-wide">
{symbol} / USDT Live Stream
</h3>
</div>
<span className="text-xs font-mono text-text-secondary uppercase">{status}</span>
</div>
<div className="text-3xl font-extrabold text-text-primary font-mono">
${lastMessage?.price?.toFixed(2) ?? '---.--'}
</div>
<div className="border-t border-border pt-3">
<div className="text-xs text-text-muted mb-2">Últimos ticks (Realtime):</div>
<div className="space-y-1 font-mono text-xs max-h-40 overflow-y-auto">
{ticks.map((tick) => (
<div key={tick.timestamp} className="flex justify-between py-0.5 text-text-secondary">
<span>{new Date(tick.timestamp).toLocaleTimeString()}</span>
<span className={tick.change24h >= 0 ? 'text-emerald-400' : 'text-red-400'}>
${tick.price.toFixed(2)}
</span>
</div>
))}
</div>
</div>
</div>
);
}Declarar const socket = new WebSocket(...) fuera de useEffect o de referencias provoca que en cada renderizado del componente se cree una conexión de red paralela adicional. En pocos segundos, la aplicación satura la tabla de descriptores de ficheros y puertos del sistema operativo.
// ❌ ANTIPATRÓN: El socket se genera en cada ciclo de render
function BadComponent() {
const ws = new WebSocket('wss://api.example.com'); // ¡Fuga masiva de conexiones!
ws.onmessage = (e) => console.log(e.data);
return <div>Realtime</div>;
}
// ✅ CORRECTO: Encapsulado en un hook con limpieza determinista
function GoodComponent() {
const { status, lastMessage } = useWebSocket({ url: 'wss://api.example.com' });
return <div>{lastMessage}</div>;
}Si miles de clientes pierden conexión simultáneamente tras el reinicio de un balanceador de carga y la aplicación utiliza una fórmula predecible (delay = attempt * 1000), todos los clientes emitirán la reconexión en el mismo segundo exacto. El backend se colapsará bajo un ataque de denegación de servicio no intencionado. Aplique siempre Full Jitter.
Si el usuario permanece sin red durante horas y la aplicación sigue acumulando eventos en un array mediante queue.push(...) sin un límite superior, el consumo de memoria crecerá sin control hasta que la pestaña del navegador colapse por Out of Memory. Establezca siempre un maxQueueSize con política de reemplazo de registros antiguos.
En modo desarrollo, React 19 monta, desmonta y vuelve a montar intencionadamente los componentes para verificar la limpieza de efectos secundarios. Si la función de cleanup no cierra explícitamente el socket mediante ws.close(), la primera conexión quedará huérfana en segundo plano recibiendo mensajes duplicados.
useWebSocket debe mantener el socket aislado de los renders de React, incorporar Full Jitter Backoff, depurar sockets zombi mediante Heartbeat Ping-Pong y acotar la cola offline.