StackPractices
intermediate Por Mathias Paulenko

Server-Sent Events (SSE): Streaming en Tiempo Real

Implementá streaming unidireccional en tiempo real del servidor al navegador con Server-Sent Events. Cubre Python, Node.js, Java, tipos de eventos, reconexión y broadcasting.

Temas: api

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:

sequenceDiagram diagram: participant B as Navegador

Campos clave:

  • data — el payload. Podés enviar múltiples líneas data:; el navegador las concatena con \n entre cada una.
  • event — el tipo de evento con nombre. El navegador lo dispatcha a addEventListener("event-name", ...) en vez del handler default onmessage.
  • id — el navegador lo usa como Last-Event-ID al 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 EventSource hay 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

EnfoqueTransporteDirecciónIdeal para
SSEHTTPServidor → clienteNotificaciones, feeds en vivo, progress bars
WebSocketTCP upgradeBidireccionalChat, gaming, edición colaborativa
Long pollingHTTPPetición → pushNavegadores legacy, actualizaciones simples
HTTP/2 SSEHTTP/2Servidor → clienteStreams compartidos, menor overhead

Buenas Prácticas

  • Seteá X-Accel-Buffering: no para evitar que Nginx u otros proxies buffericen mensajes.
  • Usá Cache-Control: no-cache para 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") u onCompletion) para evitar fugas de memoria en el registro de broadcast.
  • Usá tipos de event para 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: no o Cache-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\n final; 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

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.