En cuanto una pantalla debe actualizarse sin recargar la página, WebSocket entra en la conversación. Seguimiento de un pedido, progreso de una exportación, notificaciones, la respuesta de un LLM que aparece token a token: el reflejo es abrir un canal bidireccional. Sin embargo, en todos estos casos, el cliente no tiene nada que decirle al servidor una vez establecida la conexión.

El patrón Server-Sent Events (SSE) responde exactamente a esta necesidad con una simple respuesta HTTP que no termina. La decisión va más allá del protocolo. Determina lo que obtienes gratis (reconexión, reanudación, autenticación HTTP) y lo que tendrás que construir por tu cuenta. Comparemos ambos en Python, con FastAPI y Django, hasta llegar a los problemas que solo aparecen en producción.

En resumen:

  • SSE: la opción por defecto cuando habla el servidor, con reconexión y reanudación nativas.
  • WebSocket: cuando el cliente emite de forma continua o los datos son binarios.
  • Polling: cuando los datos cambian poco y un retraso no molesta a nadie.

SSE: una respuesta HTTP que permanece abierta

Con SSE, el navegador envía un GET normal. El servidor responde con Content-Type: text/event-stream y luego escribe bloques de texto a medida que ocurren los eventos:

id: 1726394400000-0
event: order.shipped
data: {"order_id": 42, "carrier": "colissimo"}

: ping

id: 1726394460000-0
event: order.delivered
data: {"order_id": 42}

Una línea vacía termina un evento. Una línea que empieza por : es un comentario que el cliente ignora, lo que la convierte en un keep-alive ideal. En el navegador, la API EventSource cabe en pocas líneas:

javascript
const source = new EventSource("/orders/42/events", { withCredentials: true });

source.addEventListener("order.shipped", (event) => {
  renderShipment(JSON.parse(event.data));
});

Lo más interesante es lo que este código no contiene. Si la red se corta, el navegador se reconecta solo y envía el identificador del último evento recibido en la cabecera Last-Event-ID. El servidor puede entonces continuar exactamente donde se detuvo el flujo.

WebSocket: otro protocolo después del handshake

WebSocket también empieza con una petición HTTP, pero esta solicita un cambio de protocolo (Upgrade: websocket). Tras la respuesta 101 Switching Protocols, ya no es HTTP. Cliente y servidor intercambian tramas de texto o binarias, en ambos sentidos y en cualquier momento.

Esa libertad es real, pero el protocolo se queda ahí. No hay identificadores de mensaje, ni reanudación, ni reconexión automática en la API del navegador. Todo eso queda por diseñar:

python
from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()


@app.websocket("/ws/rooms/{room_id}")
async def room(websocket: WebSocket, room_id: int) -> None:
    await websocket.accept()
    try:
        while True:
            message = await websocket.receive_json()
            await handle_client_message(room_id, message)
    except WebSocketDisconnect:
        await handle_disconnect(room_id)

Este handler parece corto, pero deja abiertas las verdaderas preguntas. ¿Cómo indica el cliente el último mensaje recibido tras un corte? ¿Quién reenvía lo que se perdió? ¿Qué ocurre si envía un mensaje durante la reconexión? Con WebSocket, tu aplicación define su propio protocolo encima del protocolo.

SSE vs WebSocket: la comparativa

CriterioServer-Sent EventsWebSocket
DirecciónDel servidor al clienteBidireccional
DatosTexto UTF-8Texto y binario
TransporteHTTP estándarProtocolo dedicado tras Upgrade
ReconexiónNativa con EventSourceA implementar en el cliente
ReanudaciónLast-Event-ID, si el servidor la gestionaProtocolo de aplicación por diseñar
AutenticaciónCookies y middlewares HTTPHandshake y luego estado propio
Protección de origenSe aplica CORSVerificar Origin es responsabilidad tuya
Límite del navegador6 conexiones por dominio en HTTP/1.1Sin un límite tan bajo
DjangoVista async bajo ASGIDjango Channels

Dos filas merecen una explicación.

El límite de conexiones. En HTTP/1.1, los navegadores limitan las conexiones a unas seis por dominio, sumando todas las pestañas. Con seis pestañas abiertas en tu aplicación y un flujo SSE en cada una, ya no sale ninguna petición más. HTTP/2 resuelve el problema multiplexando los flujos sobre una única conexión TCP. Servir SSE en HTTP/2 se convierte así en un requisito en cuanto los usuarios abren varias pestañas.

El origen. La política del mismo origen no protege un handshake WebSocket. Si la autenticación se basa en una cookie, un sitio de terceros puede abrir una conexión con las credenciales del usuario, salvo que el servidor controle la cabecera Origin. Es el Cross-Site WebSocket Hijacking. Olvidar este control es fácil, porque nada se rompe durante el desarrollo.

SSE con FastAPI: reanudar tras un corte

Desde la versión 0.135.0, FastAPI soporta SSE de forma nativa con EventSourceResponse. Se encarga del formato, envía un ping cada 15 segundos cuando el flujo está en silencio y añade las cabeceras Cache-Control: no-cache y X-Accel-Buffering: no.

El verdadero reto está en otra parte. Una respuesta SSE vive en un único worker, mientras que el evento se produce en cualquier sitio: otro worker, una tarea de Celery, otro servicio. Hace falta un intermediario, y debe conservar el historial para permitir la reanudación. Redis pub/sub difunde bien pero lo olvida todo: un cliente desconectado tres segundos pierde esos tres segundos. Redis Streams cubre la difusión y la reproducción con un solo mecanismo (consulta la comparativa de Redis Pub/Sub y Streams):

python
from collections.abc import AsyncIterable
from typing import Annotated

from fastapi import Depends, FastAPI, Header
from fastapi.sse import EventSourceResponse, ServerSentEvent
from redis.asyncio import Redis

app = FastAPI()
redis = Redis.from_url("redis://localhost:6379", decode_responses=True)

STREAM_ID_PATTERN = r"^\d+-\d+$"


async def latest_id(stream: str) -> str:
    entries = await redis.xrevrange(stream, count=1)
    return entries[0][0] if entries else "0-0"


@app.get(
    "/orders/{order_id}/events",
    response_class=EventSourceResponse,
    dependencies=[Depends(require_order_access)],
)
async def order_events(
    order_id: int,
    last_event_id: Annotated[
        str | None, Header(pattern=STREAM_ID_PATTERN)
    ] = None,
) -> AsyncIterable[ServerSentEvent]:
    stream = f"orders:{order_id}:events"
    cursor = last_event_id or await latest_id(stream)

    while True:
        batches = await redis.xread({stream: cursor}, block=5_000, count=100)
        for _, messages in batches:
            for message_id, fields in messages:
                cursor = message_id
                yield ServerSentEvent(
                    raw_data=fields["payload"],
                    event=fields["type"],
                    id=message_id,
                )

Tres detalles marcan la diferencia entre una demo y un flujo fiable.

El identificador SSE es el identificador de Redis. No hay ninguna tabla de correspondencia que mantener: Last-Event-ID se convierte directamente en el cursor de XREAD.

El cursor inicial se resuelve una sola vez con latest_id. Pasar $ en cada iteración parece más sencillo, pero $ significa «el último en el momento de la llamada»: un evento publicado entre dos XREAD se perdería.

raw_data evita una doble serialización. El campo data de ServerSentEvent siempre codifica en JSON, y payload ya es una cadena JSON.

En el lado del productor, se limita la retención:

python
await redis.xadd(
    f"orders:{order_id}:events",
    {"type": "order.shipped", "payload": payload_json},
    maxlen=1_000,
    approximate=True,
)

Esta llamada no puede hacerse en cualquier sitio. Publicar en Redis en medio de una transacción SQL es arriesgarse a anunciar un pedido enviado que luego anula un rollback. El Transactional Outbox resuelve precisamente este problema: el evento se escribe en la misma transacción que el estado de negocio, y después un relay lo envía al stream.

SSE con Django: sin necesidad de Channels

Django no tiene una abstracción SSE, pero StreamingHttpResponse acepta un iterador asíncrono bajo ASGI. Es suficiente. Aquí, el ping y las cabeceras corren de tu cuenta:

python
import re

from django.http import HttpResponseBadRequest, HttpResponseForbidden
from django.http import StreamingHttpResponse

from .models import Order
from .streams import latest_id, redis

STREAM_ID = re.compile(r"\d+-\d+")


async def event_stream(stream: str, cursor: str):
    while True:
        batches = await redis.xread({stream: cursor}, block=15_000, count=100)
        if not batches:
            yield ": ping\n\n"
            continue
        for _, messages in batches:
            for message_id, fields in messages:
                cursor = message_id
                yield (
                    f"id: {message_id}\n"
                    f"event: {fields['type']}\n"
                    f"data: {fields['payload']}\n\n"
                )


async def order_events(request, order_id: int):
    user = await request.auser()
    allowed = await Order.objects.filter(pk=order_id, owner_id=user.pk).aexists()
    if not allowed:
        return HttpResponseForbidden()

    last_event_id = request.headers.get("Last-Event-ID")
    if last_event_id and not STREAM_ID.fullmatch(last_event_id):
        return HttpResponseBadRequest()

    stream = f"orders:{order_id}:events"
    cursor = last_event_id or await latest_id(stream)

    response = StreamingHttpResponse(
        event_stream(stream, cursor),
        content_type="text/event-stream",
    )
    response["Cache-Control"] = "no-cache"
    response["X-Accel-Buffering"] = "no"
    return response

La comprobación de acceso se hace en la vista, no en el generador, para poder devolver todavía un estado HTTP real. Una vez enviado el primer byte, es demasiado tarde para responder 403.

Esta vista debe ejecutarse bajo ASGI, con Uvicorn o Daphne por ejemplo. Bajo WSGI, cada flujo abierto inmoviliza un worker durante toda su duración: unos pocos clientes bastan para bloquear la aplicación. Cuando un cliente se desconecta, Django cancela la corrutina. Si tienes recursos que liberar, captura asyncio.CancelledError y vuelve a lanzarla.

La comparación es elocuente: para enviar eventos, basta una vista. WebSocket exige Django Channels, con su routing, sus consumers y su channel layer. Ese coste se justifica cuando el cliente necesita hablar de verdad, no para mostrar el estado de un pedido.

Lo que falla en producción

Los proxies

Nginx almacena las respuestas en búfer por defecto, y tu flujo solo llega cuando el búfer se llena. La cabecera X-Accel-Buffering: no lo desactiva, pero es más seguro configurar también el proxy. Cada protocolo tiene sus necesidades:

nginx
location /orders/ {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_read_timeout 1h;
}

location /ws/ {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 1h;
}

Cuidado también con la compresión: un GZipMiddleware o una CDN que comprima el flujo puede retener los eventos en sus búferes, igual que el proxy.

El 502 que lo detiene todo

EventSource se reconecta tras un error de red, pero un estado distinto de 200 cierra la conexión definitivamente. Un 502 devuelto por el proxy durante un despliegue basta para detener el flujo, sin más intentos. Vigila ese estado:

javascript
source.onerror = () => {
  if (source.readyState === EventSource.CLOSED) {
    scheduleReconnectWithJitter();
  }
};

El retardo aleatorio importa. Sin él, todos los clientes cortados por un despliegue vuelven en el mismo segundo.

Una conexión Redis por cliente

El código anterior mantiene un XREAD bloqueante por flujo, es decir, una conexión Redis por cliente conectado. Es legible y correcto hasta cierto volumen. Más allá, cada worker debe leer los streams con un único bucle compartido y redistribuir a los clientes locales mediante asyncio.Queue acotadas. El límite es importante: un cliente lento no debe hacer crecer la memoria del worker. WebSocket tiene exactamente el mismo problema de fan-out y backpressure.

Duplicados y huecos

La reanudación no es una garantía absoluta. Si el cliente vuelve cuando MAXLEN ya ha expulsado su cursor, XREAD continúa desde las entradas aún disponibles y el hueco pasa desapercibido. Los datos del stream sirven para recuperarse de un corte, no para reconstruir un estado: tras una ausencia larga, el cliente recarga una vista completa. Es la misma separación que en un read model CQRS, donde el flujo señala un cambio y la lectura es la fuente de verdad.

Como el relay del outbox es at-least-once, un mismo evento también puede publicarse dos veces, con dos identificadores de stream distintos. Incluye un identificador de negocio en el payload e ignora lo que ya se haya procesado, siguiendo la lógica del consumidor idempotente.

La autenticación

EventSource no permite cabeceras personalizadas ni POST. Sin cookie, queda un ticket de un solo uso y corta duración en la URL, nunca el JWT principal, porque las URL acaban en los logs. La API WebSocket del navegador tiene la misma limitación con las cabeceras.

La otra opción es leer el flujo con fetch. Así funcionan las API de LLM: un POST que responde con text/event-stream. Ganas las cabeceras y el cuerpo de la petición, y pierdes la reconexión nativa. FastAPI acepta además EventSourceResponse en cualquier método HTTP.

¿Y si el cliente también debe enviar datos?

Necesitar comunicación en ambos sentidos no basta para justificar WebSocket. Si el cliente envía poco, SSE para la bajada y peticiones POST normales para la subida forman una combinación muy sólida:

Navegador ── POST /orders/42/cancel ──▶ API
Navegador ◀── event: order.cancelled ── flujo SSE

Cada acción conserva su routing, validación, permisos y códigos de error HTTP. Para que esos POST se puedan reintentar sin riesgo tras un corte, añade Idempotency Keys.

¿SSE o polling?

No son lo mismo. Con polling, el cliente pregunta sin parar si algo ha cambiado. Con SSE, pregunta una sola vez y el servidor envía cuando hay novedades. El long polling está entre ambos, y cada uno tiene su lugar: la comparativa de polling, long polling y SSE detalla cómo funcionan y cuánto cuestan.

Ten en cuenta que XREAD con block es en sí una forma de long polling, pero entre el worker y Redis. El navegador solo ve un flujo SSE continuo.

SSE o WebSocket: cómo elegir

Elige SSE cuando el flujo vaya principalmente del servidor al cliente: notificaciones, progreso de tareas, paneles, estados, streaming de respuestas de LLM. Es la opción correcta por defecto, y tu infraestructura HTTP sigue funcionando.

Elige WebSocket cuando el cliente emita de forma continua o la latencia en ambos sentidos forme parte del producto: edición colaborativa, juegos multijugador, cursores compartidos, chat con indicadores de escritura o datos binarios.

Mantén el polling si los datos cambian poco y un retraso de treinta segundos no molesta a nadie. No hay conexiones largas que operar y puedes aprovechar la caché HTTP.

La pregunta útil no es «¿qué protocolo es más potente?», sino «¿quién habla, con qué frecuencia y qué ocurre tras un corte?».

Conclusión

WebSocket es la herramienta adecuada para una verdadera conversación en ambos sentidos. Para todo lo demás, te hace pagar una reconexión, una reanudación y una seguridad que SSE o HTTP ya ofrecen.

Sea cual sea el protocolo, lo más difícil está detrás de él: un bus que difunde entre workers, un registro que permite reproducir eventos, una publicación fiable tras el commit y consumidores que toleran duplicados. Con esos cimientos en su sitio, el protocolo es solo un detalle de entrega, y SSE suele ser el más sencillo de operar.