Server-Sent Events vs WebSockets en Node.js: cuándo elegir cada uno para actualizaciones en tiempo real
Cada vez que un equipo nos pide "tiempo real" para una funcionalidad, la conversación deriva casi automáticamente hacia WebSockets, como si fuera la única herramienta disponible. En una parte considerable de esos casos, lo que realmente se necesita es que el servidor empuje datos hacia el cliente sin que el cliente tenga que enviar nada de vuelta, y para eso Server-Sent Events (SSE) resuelve el problema con una fracción de la complejidad operativa que trae montar y mantener un servidor de WebSockets.
Este artículo compara ambas tecnologías en los puntos que de verdad cambian una decisión de arquitectura: dirección del flujo de datos, reconexión, y cómo se comportan bajo la infraestructura HTTP existente. No es una comparación de rendimiento en abstracto, es la lista de preguntas que nos hacemos antes de elegir una sobre la otra en un proyecto de Node.
Dirección del flujo: la pregunta que resuelve la mitad de los casos
SSE es unidireccional por diseño: el servidor mantiene abierta una conexión HTTP y va escribiendo eventos hacia el cliente a medida que ocurren. El cliente no tiene un canal de vuelta sobre esa misma conexión; si necesita enviar algo al servidor, lo hace con una petición HTTP normal, separada del stream. WebSockets es bidireccional desde el inicio: una vez completado el handshake, ambos lados pueden enviar mensajes en cualquier momento sobre la misma conexión.
Esto convierte la primera pregunta útil en una muy concreta: ¿el cliente necesita enviar datos frecuentes hacia el servidor sobre el mismo canal que recibe las actualizaciones, o solo necesita escuchar? Un feed de notificaciones, un indicador de progreso de un job en background, un dashboard que refleja cambios hechos por otros usuarios, o un stream de logs de un despliegue son casos donde el cliente únicamente consume. Un chat, un editor colaborativo o un juego multijugador necesitan que el cliente también emita constantemente, y ahí la respuesta casi siempre es WebSockets.
Vale la pena resistir la tentación de usar WebSockets para el primer grupo de casos solo porque ya está disponible en el proyecto por otra funcionalidad. Cada conexión WebSocket que el servidor mantiene abierta es un socket con estado que hay que gestionar, escalar horizontalmente con algo como un adapter de Redis para pub/sub entre instancias, y reconectar a mano en el cliente. SSE delega buena parte de eso al navegador.
Reconexión: lo que EventSource hace gratis
La diferencia más práctica en el día a día de mantenimiento es la reconexión. El cliente de SSE en el navegador es la API EventSource, y trae reconexión automática incorporada: si la conexión se cae, el navegador reintenta después de un intervalo que el propio servidor puede sugerir con el campo retry del stream.
const source = new EventSource('/api/eventos');
source.addEventListener('actualizacion', (evento) => {
const datos = JSON.parse(evento.data);
actualizarUI(datos);
});
source.onerror = () => {
// EventSource ya está reintentando la conexión por su cuenta
console.warn('conexión SSE interrumpida, reconectando');
};
Además, cada evento puede llevar un id, y cuando el navegador reconecta envía automáticamente un header Last-Event-ID con el último id que recibió. Esto permite que el servidor retome el stream desde donde se cortó en lugar de reenviar todo desde cero, siempre que el servidor esté preparado para leer ese header y reconstruir el estado a partir de ahí.
// servidor: Express + text/event-stream
app.get('/api/eventos', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.flushHeaders();
const ultimoId = req.headers['last-event-id'];
const eventos = obtenerEventosDesde(ultimoId);
const enviar = (evento) => {
res.write(`id: ${evento.id}\n`);
res.write(`event: actualizacion\n`);
res.write(`data: ${JSON.stringify(evento.payload)}\n\n`);
};
eventos.forEach(enviar);
const suscripcion = bus.on('evento', enviar);
req.on('close', () => bus.off('evento', suscripcion));
});
WebSockets no trae nada de esto de fábrica. La librería ws entrega la conexión cruda; la lógica de reconexión, backoff exponencial, y resincronización de estado después de una caída hay que escribirla a mano en el cliente, y volver a escribirla cada vez que se cambia de librería o de framework en el frontend. No es que sea difícil, es código adicional que SSE resuelve por defecto para el caso unidireccional.
Límites de conexión y compatibilidad con la infraestructura existente
SSE es HTTP puro. Eso significa que atraviesa proxies corporativos, balanceadores de carga y firewalls sin ninguna configuración especial más allá de permitir conexiones de larga duración, reutiliza cookies y headers de autenticación existentes sin ningún paso adicional, y no requiere abrir un puerto ni un protocolo distinto en la infraestructura. WebSockets necesita un upgrade de protocolo desde HTTP, y aunque hoy la mayoría de proxies y balanceadores modernos lo soportan bien, seguimos encontrando infraestructura antigua o reglas de firewall corporativas restrictivas donde WebSocket falla silenciosamente o se degrada, mientras que una conexión SSE simplemente funciona porque para la infraestructura de red es una petición HTTP más.
El punto donde SSE históricamente perdía era el límite de conexiones concurrentes por dominio que los navegadores imponen bajo HTTP/1.1, que es bajo y compartido entre pestañas del mismo origen: abrir varias conexiones SSE hacia el mismo dominio desde distintas pestañas podía agotar ese límite y bloquear otras peticiones normales de la página. Bajo HTTP/2, las conexiones se multiplexan sobre un único socket TCP y esta restricción prácticamente desaparece, así que si el servidor sirve sobre HTTP/2 -algo cada vez más común por defecto en la mayoría de plataformas de hosting- este argumento en contra de SSE pierde peso.
Dónde SSE evita complejidad real
En Node concretamente, montar SSE no requiere una librería aparte: es un endpoint HTTP normal que mantiene la respuesta abierta y escribe con el formato text/event-stream. No hay handshake especial, no hay que gestionar un protocolo binario ni de texto distinto al de cualquier otra ruta, y el mismo middleware de autenticación que protege el resto de la API protege también el stream sin cambios. Escalar horizontalmente sigue necesitando que los eventos lleguen a la instancia correcta -normalmente vía un pub/sub como Redis- pero no exige mantener el estado de una conexión bidireccional persistente por cliente, solo reenviar hacia una respuesta HTTP abierta.
Los casos donde recomendamos SSE sobre WebSockets sin dudarlo: notificaciones push desde el servidor, progreso de tareas en background (procesamiento de archivos, generación de reportes), streaming de logs de un pipeline de CI/CD, y actualizaciones de estado de un dashboard donde el cliente nunca necesita escribir sobre ese mismo canal. En cualquiera de estos, montar un servidor de WebSockets agrega infraestructura -gestión de conexiones con estado, reconexión manual, en muchos casos un adapter de pub/sub- para resolver un problema que ya viene resuelto por el navegador con EventSource y unas pocas líneas de código en el servidor.
Cuándo sí vale la pena WebSockets
Cuando el cliente necesita enviar datos con la misma frecuencia y urgencia con la que los recibe -chat en tiempo real, cursores colaborativos en un editor tipo Google Docs, estado de un juego multijugador, control remoto de un dispositivo- la respuesta es WebSockets sin rodeos. Intentar resolver esos casos con SSE más peticiones HTTP separadas para el canal de subida termina reconstruyendo, con más piezas móviles, algo que WebSockets ya resuelve en una sola conexión.
La decisión, al final, no depende de cuál tecnología es "más moderna" o "más rápida" en abstracto, sino de una pregunta simple sobre la forma real del tráfico: si es básicamente en una dirección, SSE. Si es genuinamente en las dos, WebSockets. Elegir WebSockets por defecto para el primer caso es la fuente más común de complejidad que no hacía falta.