Resumen
Server-Sent Events (SSE) es una API del navegador y un protocolo basado en HTTP que
permite al servidor enviar actualizaciones en tiempo real a los clientes sobre una única
conexión persistente. A diferencia de
WebSockets, SSE es unidireccional: servidor → cliente.
SSE corre sobre HTTP plano, atraviesa la mayoría de firewalls y proxies, tiene
reconexión automática con Last-Event-ID y no necesita upgrade de protocolo. Ese último
punto importa más de lo que la gente cree — significa que tu infraestructura HTTP
existente (load balancers, CDNs, monitoring) simplemente funciona.
Usé SSE en producción para dashboards en vivo y sistemas de notificaciones, y me ahorró la complejidad de WebSockets más de una vez. Cuando solo necesitás push servidor→cliente — pensá en tickers de bolsa, resultados en vivo, progress bars o tails de logs — SSE es más simple de deployear, más fácil de debuggear, y funciona bien con infraestructura HTTP existente como CDN edge caching y load balancers.
Cuándo Usar
- Necesitás actualizaciones en tiempo real del servidor al cliente: resultados en vivo, precios, notificaciones o logs.
- El flujo de datos es principalmente unidireccional: el servidor empuja y el cliente solo escucha.
- Querés auto-reconexión sin tener que armar tu propia lógica de reconexión WebSocket.
- Necesitás algo simple que funcione con firewalls corporativos y proxies HTTP — sin upgrade de protocolo, sin puertos especiales.
- Ya estás sirviendo una REST API y querés agregar un endpoint de streaming sin introducir un protocolo nuevo.
Cuándo NO Usar
- Para chat, gaming o edición colaborativa: usá WebSockets.
- Para datos binarios: SSE solo soporta texto UTF-8; usá base64 o WebSockets.
- Cuando el cliente necesita enviar mensajes frecuentes al servidor.
- Cuando necesitás streaming de gRPC para microservicios polyglot — gRPC soporta streaming bidireccional con tipado fuerte.
Solución
Python con Flask
from flask import Flask, Response
import json
import time
from queue import Queue
app = Flask(__name__)
@app.route("/events")
def events():
def generate():
counter = 0
while True:
counter += 1
data = {"message": f"Update {counter}", "timestamp": time.time()}
yield f"data: {json.dumps(data)}\n\n"
time.sleep(2)
return Response(generate(), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
# Broadcasting a múltiples clientes
clients = []
@app.route("/broadcast")
def broadcast_stream():
q = Queue()
clients.append(q)
def generate():
try:
while True:
msg = q.get()
yield f"data: {json.dumps(msg)}\n\n"
finally:
clients.remove(q)
return Response(generate(), mimetype="text/event-stream")
Node.js con Express
const express = require("express");
const app = express();
app.get("/events", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
let counter = 0;
const interval = setInterval(() => {
counter++;
const data = JSON.stringify({
message: `Update ${counter}`,
timestamp: Date.now()
});
res.write(`data: ${data}\n\n`);
}, 2000);
req.on("close", () => clearInterval(interval));
});
// Broadcasting
const clients = new Set();
app.get("/broadcast", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
clients.add(res);
req.on("close", () => clients.delete(res));
});
function broadcastToAll(data) {
const message = `data: ${JSON.stringify(data)}\n\n`;
clients.forEach(client => client.write(message));
}
Java con Spring Boot
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@RestController
public class SseController {
private final CopyOnWriteArrayList<SseEmitter> emitters = new CopyOnWriteArrayList<>();
private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor();
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamEvents() {
SseEmitter emitter = new SseEmitter(0L);
emitters.add(emitter);
emitter.onCompletion(() -> emitters.remove(emitter));
emitter.onTimeout(() -> emitters.remove(emitter));
emitter.onError((e) -> emitters.remove(emitter));
scheduler.scheduleAtFixedRate(() -> {
try {
emitter.send(SseEmitter.event()
.data("{\"message\": \"Update\"}"));
} catch (IOException e) {
emitters.remove(emitter);
}
}, 0, 2, TimeUnit.SECONDS);
return emitter;
}
public void broadcast(String message) {
for (SseEmitter emitter : emitters) {
try {
emitter.send(SseEmitter.event().data(message));
} catch (IOException e) {
emitters.remove(emitter);
}
}
}
}
Cliente del navegador
const eventSource = new EventSource("/events");
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("Received:", data);
};
eventSource.onerror = (error) => {
console.error("SSE error:", error);
};
// Eventos con nombre
const notifications = new EventSource("/notifications");
notifications.addEventListener("alert", (e) => {
const data = JSON.parse(e.data);
showAlert(data.msg);
});
Eventos con nombre y heartbeats
def generate():
yield "event: connected\ndata: \"Stream started\"\n\n"
for i in range(1, 10):
event_type = "alert" if i % 3 == 0 else "info"
data = {"level": event_type, "msg": f"Notification {i}"}
yield f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
# Heartbeat comment
yield ": heartbeat\n\n"
Explicación
SSE corre sobre HTTP plano con Content-Type: text/event-stream. Cada mensaje es un
conjunto de líneas field: value terminadas con una línea en blanco. La API EventSource
del navegador maneja todo el ciclo de vida de conexión — open, auto-reconnect y parse —
así que no tenés que hacerlo vos.
Acá está el ciclo de vida completo de conexión:
Campos clave:
data— el payload. Podés enviar múltiples líneasdata:; el navegador las concatena con\nentre cada una.event— el tipo de evento con nombre. El navegador lo dispatcha aaddEventListener("event-name", ...)en vez del handler defaultonmessage.id— el navegador lo usa comoLast-Event-IDal reconectar. Así implementás streams resumibles: el servidor lee el header y replaya los eventos perdidos.retry— demora de reconexión en milisegundos. Le dice al navegador cuánto pausar antes de intentar reconectar después de una caída.: comment— una línea de heartbeat. Mantiene la conexión activa en proxies y load balancers sin entregar datos al cliente.
Si la conexión se cae, el navegador espera el intervalo retry y se reconecta enviando el
último id recibido. El servidor puede usar ese header para reanudar desde el punto
correcto. Una vez debuggeé un stream SSE que se reconectaba cada 30 segundos — resultó que
Nginx estaba bufferando la respuesta porque olvidé X-Accel-Buffering: no. El navegador
no veía llegar datos, asumía que la conexión estaba muerta y reconectaba. Error clásico.
Debuggear conexiones SSE
La forma más rápida de testear un endpoint SSE es curl -N:
# -N deshabilita el output buffering de curl para ver eventos en tiempo real
curl -N -H "Accept: text/event-stream" http://localhost:3000/events
Si ves eventos llegando de a uno, tu servidor y proxy están bien configurados. Si ves una
ráfaga de eventos después de una demora, algo está bufferizando — checkeá
proxy_buffering de Nginx, el “Rocket Loader” de Cloudflare, o cualquier CDN que esté
delante de tu origin.
Para monitoring en producción, trackeá estas métricas:
- Conexiones activas — cuántos clientes
EventSourcehay conectados por instancia. - Eventos por segundo — cuánto throughput maneja cada endpoint.
- Tasa de reconexión — si los clientes reconectan seguido, tu config de proxy o heartbeat está mal.
- Memoria por conexión — cada conexión SSE tiene un response object en memoria. Con 10k clientes concurrentes, se acumula rápido.
Escalar SSE con Redis pub/sub
Cuando tenés múltiples instancias detrás de un load balancer, cada instancia solo conoce sus clientes locales. Para hacer broadcast a todos los clientes across todas las instancias, usá una capa pub/sub — Redis funciona bien para esto:
import redis
import json
r = redis.Redis(host="localhost", port=6379)
def broadcast_to_all_instances(channel, data):
r.publish(channel, json.dumps(data))
# Cada instancia del servidor se suscribe y pushea a los clientes SSE locales
pubsub = r.pubsub()
pubsub.subscribe("sse-broadcast")
for message in pubsub.listen():
if message["type"] == "message":
for q in list(clients):
q.put(json.loads(message["data"]))
Este patrón escala horizontalmente — podés agregar más instancias y todas se suscriben al mismo canal de Redis. Lo corrí en producción con 10k+ clientes concurrentes across 4 instancias Node.js, y Redis pub/sub manejó el fan-out sin problemas.
Variantes
| Enfoque | Transporte | Dirección | Ideal para |
|---|---|---|---|
| SSE | HTTP | Servidor → cliente | Notificaciones, feeds en vivo, progress bars |
| WebSocket | TCP upgrade | Bidireccional | Chat, gaming, edición colaborativa |
| Long polling | HTTP | Petición → push | Navegadores legacy, actualizaciones simples |
| HTTP/2 SSE | HTTP/2 | Servidor → cliente | Streams compartidos, menor overhead |
Buenas Prácticas
- Seteá
X-Accel-Buffering: nopara evitar que Nginx u otros proxies buffericen mensajes. - Usá
Cache-Control: no-cachepara que navegadores y proxies no cacheen el stream. - Enviá comentarios de heartbeat (
: ping\n\n) cada 15-30 segundos para mantener conexiones inactivas. - Manejá las desconexiones inmediatamente (
req.on("close")uonCompletion) para evitar fugas de memoria en el registro de broadcast. - Usá tipos de
eventpara rutear del lado del cliente. No metas el tipo dentro del payload JSON — eso fuerza a cada cliente a parsear JSON solo para decidir qué handler llamar.
Errores Comunes
- Olvidar
X-Accel-Buffering: nooCache-Control: no-cache, lo que genera entrega retardada en lotes. - No limpiar clientes desconectados, causando fugas de memoria.
- Enviar datos SSE sin el terminador
\n\nfinal; el navegador espera indefinidamente. - Usar SSE para chat bidireccional — cambiá a WebSockets. SSE es unidireccional solamente.
- Enviar datos binarios directamente; SSE solo soporta texto UTF-8.
Ver También
- MDN: Server-Sent Events — referencia de la API del navegador
- HTML Living Standard: Server-sent events — especificación del protocolo
- Spring: SseEmitter Javadoc — SSE del lado del servidor en Java
- Flask: Streaming responses — patrones de streaming en Python
- Cloudflare: Understanding SSE — consideraciones de CDN
- WebSocket server — alternativa bidireccional a SSE
- Real-time notifications — patrones de sistemas de notificaciones
Preguntas frecuentes
¿En qué se diferencia SSE de WebSockets?
SSE corre sobre HTTP estándar, es unidireccional, tiene reconexión automática con
Last-Event-ID y atraviesa la mayoría de firewalls y proxies. WebSockets necesitan
upgrade de protocolo, soportan comunicación bidireccional y tenés que escribir tu propia
lógica de reconexión.
¿Por qué mi conexión SSE se cae cada 30 segundos?
Probablemente estás detrás de un proxy o CDN que bufferiza la respuesta. Seteá
X-Accel-Buffering: no para Nginx, deshabilitá el proxy buffering en tu CDN, y enviá
comentarios de heartbeat (: ping\n\n) cada 15-30 segundos para mantener la conexión
activa. Pasé dos días debuggeando esto una vez — la solución fue un header.
¿Cuál es el número máximo de conexiones SSE por navegador?
Los navegadores limitan SSE a 6 conexiones concurrentes por dominio en HTTP/1.1. HTTP/2 elimina ese límite al correr streams concurrentes sobre una sola conexión TCP. Si necesitás más de 6 endpoints SSE en HTTP/1.1, usá un único endpoint con eventos con nombre en vez de abrir conexiones separadas.
¿Cómo reanudo después de una interrupción de red?
El navegador trackea el último id recibido y lo envía como header Last-Event-ID al
reconectar. El servidor lee ese header y reanuda desde ese punto. Si no se envió un id, el navegador no puede resumir — el stream arranca de nuevo. Está
bien para datos efímeros como resultados en vivo, pero malo para logs de eventos ordenados.
¿Cómo escalo SSE a muchos clientes?
Usá un broker de mensajes o pub/sub para fan-out de eventos. Cada instancia del servidor
mantiene un registro de conexiones EventSource o SseEmitter locales. El broker empuja
nuevos eventos a todas las instancias, que luego hacen broadcast a sus clientes locales.
Recursos Relacionados
Versionado de APIs
Cómo versionar APIs REST y GraphQL para mantener compatibilidad hacia atrás mientras evolucionas tu interfaz.
RecipeLlamar a una API REST: Python, JS, Java y Go
Cómo hacer peticiones HTTP a una API REST y manejar la respuesta JSON en Python, JavaScript, Java y Go.
RecipeAPI gRPC con Protocol Buffers
Implementa una API gRPC con Protocol Buffers. Cubre definición de servicios, generación de código y ejemplos cliente/servidor en Python, Java y Go.
RecipeManejar Errores en APIs con RFC 7807
Patrones para un manejo de errores de API consistente y predecible en varios lenguajes y frameworks.
RecipeConstruir notificaciones en tiempo real con WebSockets
Implementa un sistema de notificaciones en tiempo real usando WebSockets y Redis pub/sub para difundir mensajes entre clientes.
RecipeImplementa Server-Sent Events en Go para Actualizaciones
Construí un endpoint de Server-Sent Events en Go listo para producción con gestión de conexiones, heartbeats y manejo graceful de desconexiones de clientes.