Un endpoint HTTP puede quedarse abierto durante horas y enviar datos nuevos cada vez que algo cambia, sin que el cliente vuelva a preguntar. Eso es exactamente lo que hacen los Server-Sent Events (SSE): una respuesta HTTP que nunca termina de escribirse, leída por el navegador como un flujo continuo de eventos a través de una sola API nativa, EventSource.
A diferencia de WebSocket, no hace falta un protocolo nuevo ni una librería externa: es HTTP puro, con un content-type distinto y un formato de texto simple. Muchos paneles en vivo, notificaciones y barras de progreso que hoy usan WebSocket podrían resolverse con Server-Sent Events, con menos código y menos infraestructura que mantener.
TL;DR
- Vas a entender cómo mantener una conexión HTTP abierta para recibir actualizaciones sin hacer polling.
- Vas a montar un servidor Server-Sent Events en Node.js con Express en menos de 20 líneas.
- Vas a manejar la reconexión automática y el campo
idpara retomar el stream donde quedó. - Vas a distinguir cuándo conviene SSE sobre WebSocket, long polling o Fetch con ReadableStream.
- Vas a identificar el límite de 6 conexiones por dominio en HTTP/1.1 y cómo esquivarlo.
- Vas a depurar el stream crudo con
curl -Nsin depender del navegador. - Vas a reconocer el formato que usan APIs como la de Anthropic para transmitir tokens en vivo.
Qué es Server-Sent Events y por qué importa
Pensalo como sintonizar una radio: te conectás una sola vez y después solo escuchás lo que se transmite, sin levantar el teléfono cada minuto para preguntar si hay una canción nueva. Eso es Server-Sent Events comparado con el polling tradicional, donde el cliente llama una y otra vez a preguntar si hay algo nuevo.
Server-Sent Events es una especificación de HTML5, formalizada en el WHATWG HTML Living Standard, que define cómo un servidor puede enviar actualizaciones a un cliente web sobre una única conexión HTTP de larga duración. El cliente hace una petición GET, el servidor responde con Content-Type: text/event-stream y, en vez de cerrar la respuesta, la va escribiendo de a pedazos mientras haya algo nuevo que contar.
La pieza clave del lado del navegador es EventSource, una API disponible de forma nativa desde hace más de una década en todos los navegadores modernos. Abrís una conexión con una línea de código, escuchás eventos con addEventListener y el propio navegador se encarga de reconectar si la conexión se corta. Nada de eso existe gratis en WebSocket: ahí la reconexión, el formato de los mensajes y el manejo de errores los tenés que escribir vos.
Server-Sent Events se estandarizó junto a WebSocket dentro de la misma camada de APIs de HTML5, pero WebSocket se llevó la atención por permitir comunicación bidireccional. Server-Sent Events quedó como la opción menos conocida, a pesar de resolver el caso más frecuente en una aplicación real: el servidor avisa, el cliente escucha. Notificaciones, progreso de una tarea, precios que cambian, logs que se van imprimiendo, tokens de un modelo de lenguaje generados uno por uno.
Cómo funciona por dentro
El formato de datos de Server-Sent Events es texto plano, UTF-8, y se apoya en cuatro campos: data, event, id y retry. Cada evento se separa del siguiente con una línea en blanco; esa línea vacía es el delimitador que le dice al navegador que ahí termina el evento y hay que procesarlo.
data: hola desde el servidor
event: pedido-actualizado
id: 42
data: {"estado":"listo"}
retry: 10000
data: reconectate en 10 segundos si me pierdo
El campo data es el único obligatorio; si no ponés event, el navegador dispara el evento genérico message. El campo id es el que hace posible la recuperación tras un corte: cada vez que el navegador reconecta, manda automáticamente el último id que vio en la cabecera Last-Event-ID, y el servidor puede usar ese valor para retomar el stream exactamente donde se cortó en lugar de reenviar todo desde cero. El campo retry le dice al navegador cuántos milisegundos esperar antes de reintentar; si no lo mandás, el valor por defecto ronda los 3 segundos.
Si el valor de data ocupa más de una línea, alcanza con repetir el campo en cada línea: el navegador las concatena separadas por saltos de línea antes de exponerlas en evento.data.
data: primera linea
data: segunda linea
Ese fragmento llega al cliente como un único evento donde evento.data vale "primera linea\nsegunda linea". Es el mismo mecanismo que usan las APIs de streaming de modelos de lenguaje para ir mandando texto parcial sin esperar la respuesta completa.
sequenceDiagram
participant B as Navegador
participant S as Servidor
B->>S: GET /eventos con Accept text/event-stream
S-->>B: 200 OK Content-Type text/event-stream
loop mientras la conexion siga abierta
S-->>B: data nuevo evento
end
Note over B,S: si la conexion se corta el navegador reconecta solo
B->>S: GET /eventos con Last-Event-ID 42
Esa reconexión automática es la razón por la que Server-Sent Events resulta tan simple de usar: el navegador implementa el reintento y el reenvío del último id sin que el desarrollador escriba una sola línea de manejo de errores para el caso común.
El campo id es lo que permite retomar el stream tras un corte de red.
Ejemplos prácticos
El ejemplo más chico posible usa el módulo http de Node.js sin ninguna dependencia:
const http = require('node:http');
http.createServer((req, res) => {
if (req.url === '/eventos') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
res.write('data: hola desde el servidor\n\n');
setInterval(() => {
res.write(`data: hora del servidor ${new Date().toISOString()}\n\n`);
}, 5000);
}
}).listen(3000);
Este servidor responde a /eventos con las tres cabeceras que exige el formato, escribe un primer evento y después uno nuevo cada 5 segundos. El resultado esperado: la conexión nunca se cierra y cada 5 segundos aparece una línea nueva en el stream.
La versión realista agrega eventos con nombre propio, un id incremental para poder reanudar, y un heartbeat para que los proxies intermedios no den por muerta la conexión:
import express from 'express';
const app = express();
let ultimoId = 0;
app.get('/eventos', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
res.flushHeaders();
const desdeId = Number(req.headers['last-event-id']) || 0;
console.log('cliente reconectado desde el evento', desdeId);
const heartbeat = setInterval(() => res.write(':\n\n'), 15000);
const enviarPedido = setInterval(() => {
ultimoId += 1;
const pedido = { id: ultimoId, estado: 'en_preparacion' };
res.write(`id: ${ultimoId}\n`);
res.write('event: pedido-actualizado\n');
res.write(`data: ${JSON.stringify(pedido)}\n\n`);
}, 4000);
req.on('close', () => {
clearInterval(heartbeat);
clearInterval(enviarPedido);
});
});
app.listen(3000);
La línea ':\n\n' es un comentario SSE válido (todo lo que arranca con dos puntos se ignora como dato, pero cuenta como tráfico) y sirve para mantener viva la conexión a través de balanceadores y proxies que cortan sockets inactivos. El header X-Accel-Buffering: no es específico de nginx y evita que el proxy junte varios eventos antes de mandarlos, lo que rompería la sensación de tiempo real.
Del lado del navegador, consumir ese stream es así:
const fuente = new EventSource('/eventos');
fuente.addEventListener('pedido-actualizado', (evento) => {
const pedido = JSON.parse(evento.data);
console.log(`pedido ${pedido.id} paso a estado ${pedido.estado}`);
});
fuente.onerror = () => {
console.log('conexion perdida, reintentando, readyState:', fuente.readyState);
};
EventSource abre la conexión sola apenas se instancia. Como el servidor manda event: pedido-actualizado, hay que escuchar ese nombre puntual con addEventListener en vez del genérico message. Si el servidor corta la conexión, onerror se dispara, pero no hace falta reconectar a mano: el navegador ya lo está haciendo en paralelo.
Cómo empezar paso a paso
Para probar el ejemplo de Express en tu máquina:
mkdir sse-demo && cd sse-demo
npm init -y
npm install express
Guardá el código del servidor en server.js y arrancalo:
node server.js
Para verificar que el stream funciona sin abrir el navegador, usá curl con la flag -N (no-buffer), que es la que confirma que estás viendo los eventos a medida que llegan y no todos juntos al final:
curl -N http://localhost:3000/eventos
Vas a ver aparecer líneas id:, event: y data: cada 4 segundos, más un comentario vacío cada 15 segundos por el heartbeat. Si en cambio todo el contenido llega de golpe cuando cortás la conexión, algo (tu código o un proxy intermedio) está buffereando la respuesta y hay que revisar las cabeceras.
Para verlo desde el navegador, abrí las DevTools, pestaña Network, hacé la petición a /eventos y seleccioná la subpestaña "EventStream" en Chrome (o "Respuesta" en Firefox): ahí aparece cada evento individual con su marca de tiempo, sin necesidad de escribir código de cliente todavía.
💡 Tip: mandá un comentario SSE (una línea que arranca con dos puntos) cada 15 o 20 segundos como heartbeat. Muchos load balancers y proxies cortan conexiones que estuvieron 30 o 60 segundos sin tráfico, y un heartbeat evita ese corte silencioso.
Un solo servidor Node.js puede sostener miles de conexiones SSE abiertas a la vez.
flowchart TD
A["Navegador (EventSource)"] --> B["Servidor Node.js o Express"]
B --> C["Generador de eventos"]
C --> D[("Cola de mensajes o base de datos")]
subgraph Backend
B
C
D
end
Casos de uso reales
El caso más común es una barra de progreso o un panel de estado: una tarea larga (procesar un video, entrenar un modelo, migrar una base de datos) que va emitiendo su avance mientras corre en el servidor. El cliente abre un EventSource al arrancar la tarea y listo, no necesita volver a preguntar si ya terminó cada dos segundos.
Otro caso frecuente son las notificaciones dentro de una aplicación: contadores de mensajes nuevos, alertas, cambios de estado de un pedido, lecturas de sensores IoT en un dashboard. Todo eso fluye en una sola dirección, del servidor al cliente, que es exactamente el modelo que Server-Sent Events resuelve mejor que WebSocket. Herramientas de logs en vivo también lo usan: un tail -f expuesto por HTTP en vez de un socket dedicado.
El caso más relevante en 2026 es el streaming de respuestas de modelos de lenguaje. Tanto la API de Anthropic para mensajes en streaming como la de OpenAI devuelven la respuesta token por token usando el mismo formato text/event-stream explicado en este artículo: cada token nuevo llega como un evento data: independiente, y el cliente los va concatenando a medida que llegan. Es la razón por la que un chat con un modelo de lenguaje muestra el texto apareciendo palabra por palabra en vez de esperar la respuesta completa.
Errores comunes y buenas prácticas
El error más frecuente es olvidar que un proxy intermedio (nginx, un load balancer, un CDN) puede bufferear la respuesta antes de mandarla al cliente, lo que hace que todos los eventos lleguen juntos al final en vez de en tiempo real. La cabecera X-Accel-Buffering: no resuelve esto en nginx; otros proxies tienen su propia variante y hay que revisar su documentación puntual.
⚠️ Ojo: si tu servidor está detrás de nginx y el stream "funciona" pero llega todo de golpe al cerrar la conexión, el problema casi siempre es el buffering del proxy, no tu código.
Otro error común es no aprovechar el campo id. Sin él, cuando el navegador reconecta después de un corte de red, el servidor no tiene forma de saber qué eventos ya se entregaron y termina reenviando todo desde el principio o, peor, saltándose eventos que se perdieron en el medio. Un id incremental por evento, guardado en el servidor o en una cola con soporte de reanudación, es lo que hace que la reconexión sea transparente para el usuario.
También es común subestimar el límite de conexiones simultáneas por dominio que impone HTTP/1.1: los navegadores permiten hasta 6 conexiones abiertas por dominio a la vez. Si tenés varias pestañas abiertas de tu aplicación, o varios componentes que abren su propio EventSource al mismo dominio, podés agotar ese límite y bloquear otras peticiones normales del sitio.
En aplicaciones de una sola página, otro descuido habitual es no cerrar el EventSource cuando un componente se desmonta o el usuario navega a otra vista: la conexión queda abierta en segundo plano y se acumulan sockets huérfanos. Llamar a fuente.close() en el cleanup del componente evita esa fuga.
Por último, Server-Sent Events es estrictamente unidireccional. Si el cliente necesita mandar algo de vuelta (un ack, un comando, una respuesta), esa parte va por un endpoint HTTP normal aparte, con un POST convencional; SSE no reemplaza esa mitad del problema, solo resuelve el envío del servidor al cliente.
Comparativa con alternativas
OpciónCuándo usarlaVentajaLimitación
Server-Sent EventsEl servidor empuja datos y el cliente no necesita responder por el mismo canalHTTP puro, reconexión automática, formato de texto simpleUnidireccional, límite de 6 conexiones por dominio en HTTP/1.1
WebSocketChat, juegos o cualquier caso genuinamente bidireccional y de baja latenciaFull-duplex, soporta datos binariosProtocolo aparte, sin reconexión automática nativa, más pieza de infraestructura
Long pollingCompatibilidad con clientes muy viejos que no soportan streamingFunciona en cualquier navegador desde hace más de 20 añosMás peticiones HTTP, más latencia, más carga en el servidor
Fetch con ReadableStreamNecesitás mandar cabeceras custom o un método distinto de GET en la petición inicialControl total sobre el parseo del streamHay que reimplementar a mano la reconexión y el formato de eventos
Profundizando (avanzado)
El límite de 6 conexiones por dominio es una restricción de HTTP/1.1, no del protocolo de Server-Sent Events en sí. Sobre HTTP/2, todas las peticiones a un mismo origen viajan multiplexadas dentro de una única conexión TCP, así que ese techo prácticamente desaparece: podés tener decenas de streams SSE abiertos al mismo dominio sin bloquear el resto del tráfico. Servir tu aplicación por HTTP/2 (la mayoría de los proxies modernos lo activan solos si hay TLS de por medio) es, en la práctica, el arreglo más simple a ese problema.
Escalar Server-Sent Events a más de un proceso o más de un servidor tiene una complicación extra: cada conexión SSE vive pegada al proceso que la abrió. Si tu backend corre en varias instancias detrás de un balanceador, un evento generado en la instancia A no llega solo a los clientes conectados a la instancia B. La solución habitual es un canal de publicación y suscripción compartido (Redis Pub/Sub, un broker como NATS, o una cola) del que cada instancia lee y reenvía a sus propios clientes conectados.
Del lado de la memoria, mantener miles de conexiones SSE abiertas es más barato de lo que parece en un runtime con I/O no bloqueante: Node.js, con su event loop de un solo hilo, sostiene conexiones inactivas casi gratis porque no hay un hilo del sistema operativo bloqueado por cada una. Lo mismo aplica a Python con asyncio o a Go con goroutines livianas. El costo real está en la cantidad de datos que efectivamente circulan, no en la cantidad de sockets abiertos.
stateDiagram-v2
[*] --> CONNECTING
CONNECTING --> OPEN: conexion establecida
OPEN --> CONNECTING: el servidor corta el stream
CONNECTING --> CLOSED: se llama a close()
OPEN --> CLOSED: se llama a close()
CLOSED --> [*]
💭 Clave: el
readyStatede unEventSourcepasa solo de CONNECTING a OPEN y vuelve a CONNECTING cada vez que hay un corte: el navegador nunca queda en un estado de error permanente salvo que llames aclose()vos mismo.
📖 Resumen en Telegram: Ver resumen
Tu próximo paso: cloná el servidor de Express de este artículo, corré curl -N contra /eventos para ver el stream crudo, y después conectá un EventSource desde la consola del navegador para comparar lo que ves en cada capa.
Preguntas frecuentes
¿Server-Sent Events funciona detrás de un proxy como nginx?
Sí, pero hay que desactivar el buffering explícitamente con la cabecera X-Accel-Buffering: no; sin eso, nginx junta los eventos antes de mandarlos y se pierde el efecto de tiempo real.
¿Puedo mandar datos binarios por Server-Sent Events?
No de forma directa: el formato es texto UTF-8. Para binario hay que codificarlo en base64 dentro del campo data, lo que agrega overhead, o usar WebSocket si el binario es el caso principal.
¿Qué pasa si se agotan las 6 conexiones por dominio?
Cualquier otra petición HTTP a ese mismo dominio, incluidas imágenes o llamadas a la API, queda en cola hasta que se libere un slot. Servir la app por HTTP/2 elimina ese límite porque multiplexa todo sobre una sola conexión TCP.
¿Cómo evito eventos duplicados al reconectar?
Usando el campo id en cada evento. El navegador reenvía automáticamente el último id recibido en la cabecera Last-Event-ID, y el servidor tiene que usar ese valor para retomar el stream en vez de reenviar todo desde el principio.
¿Server-Sent Events sirve para el streaming de respuestas de un modelo de lenguaje?
Sí. La API de streaming de Anthropic y la de OpenAI usan exactamente este formato para devolver la respuesta token por token a medida que el modelo la genera.
¿Necesito una librería para usar Server-Sent Events en el navegador?
No. EventSource es una API nativa disponible en todos los navegadores modernos. Solo hace falta un polyfill si necesitás soportar Internet Explorer.
Referencias
- MDN, Using server-sent events: guía oficial del formato text/event-stream y de la API EventSource.
- WHATWG HTML Living Standard: especificación formal del protocolo de Server-Sent Events.
- Wikipedia, Server-sent events: historia y contexto del estándar dentro de HTML5.
- Anthropic, Streaming Messages: ejemplo real de una API que usa el formato SSE para transmitir tokens de un modelo en tiempo real.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
Top comments (0)