Blogs / SSE vs. WebSockets en FastAPI: ¿Qué Elegir?

SSE vs. WebSockets en FastAPI: ¿Qué Elegir?

Publicado
31 de agosto de 2026
Autor
Faizan Nadeem
Etiquetas
FastAPI Python WebSockets LLM
Cables de fibra óptica azules irradiando hacia afuera con luz brillante viajando a través de ellos
Foto por S A en Unsplash

Todo equipo que crea una funcionalidad respaldada por un LLM llega al mismo punto de decisión: el modelo transmite tokens a medida que se generan, y el frontend necesita mostrarlos apareciendo uno a uno en lugar de esperar la respuesta completa. La respuesta por reflejo suele ser “WebSockets, porque streaming” — y luego, tres semanas después, alguien está depurando por qué la UI del chat funciona perfectamente en localhost y llega en un único bloque gigante en producción, porque un proxy nginx o un balanceador de carga corporativo almacenó en búfer toda la respuesta antes de reenviar un solo byte.

La decisión real no es “streaming vs no streaming” — es elegir el transporte correcto para un flujo de datos específico, y WebSockets con frecuencia es la respuesta equivocada para lo que, por debajo del entusiasmo por las UIs en tiempo real, es un flujo unidireccional de texto desde el servidor al cliente. Los Server-Sent Events (SSE) existen específicamente para esa forma de problema, funcionan sobre HTTP simple y evitan toda una categoría de complejidad de infraestructura que WebSockets introduce — pero SSE tiene sus propios bordes filosos en torno al buffering de proxies que toman a los equipos por sorpresa la primera vez que despliegan detrás de algo más que un proceso Uvicorn desnudo. Este artículo explica cuándo cada uno de los tres transportes es realmente correcto, qué rompe el streaming en la infraestructura de producción y ofrece código funcional de FastAPI tanto para streaming de tokens con SSE como con WebSocket.

Aprenderás:

  • Por qué el polling sigue siendo la elección correcta para una parte significativa de las funcionalidades “casi en tiempo real”, y cuándo deja de ser suficiente
  • Cómo funcionan los Server-Sent Events sobre HTTP simple, y por qué encajan mejor con el streaming de tokens de LLM de lo que la mayoría de los equipos supone al principio
  • Cuándo realmente necesitas WebSockets — los casos específicos bidireccionales y de baja latencia que SSE no puede cubrir
  • La configuración de buffering, gzip y proxy en nginx que rompe silenciosamente las respuestas en streaming en producción
  • Código funcional de FastAPI para SSE basado en StreamingResponse y para un endpoint de WebSocket
  • Un marco de decisión que puedes aplicar directamente en lugar de recurrir por defecto al transporte que esté más de moda

Tabla de contenidos

  1. Lo básico
  2. Polling: sigue siendo correcto a veces
  3. Server-Sent Events para salida token por token
  4. WebSockets: cuando realmente necesitas bidireccionalidad
  5. Manejo de desconexiones y errores a mitad del stream
  6. Estructurar endpoints de streaming en una aplicación más grande
  7. El marco de decisión
  8. Problemas de buffering y proxy que rompen el streaming
  9. Ejemplos de código
  10. Errores comunes
  11. Buenas prácticas de producción

Lo básico

Tres transportes, tres formas diferentes de problema

Polling es que el cliente pregunte repetidamente “¿hay algo nuevo?” con un temporizador. Server-Sent Events es que el servidor mantenga una conexión HTTP abierta y envíe eventos de texto por ella a medida que ocurren, en una sola dirección, del servidor al cliente. WebSockets es una conexión full-duplex donde ambos lados pueden enviar mensajes en cualquier momento, completamente independientes de un ciclo de solicitud/respuesta.

El error que cometen la mayoría de los equipos es tratar esto como una escalera de dificultad — polling es básico, SSE es mejor, WebSockets es lo mejor — cuando en realidad es una decisión de forma. Si tus datos solo fluyen en una dirección (tokens de LLM transmitiéndose a una UI de chat, una barra de progreso, una cola de logs en vivo), WebSockets resuelve un problema que no tienes mientras añade infraestructura que sí debes mantener: estado de conexión, lógica de reconexión y un protocolo que no encaja tan limpiamente detrás de herramientas HTTP estándar.

Por qué esta decisión importa más específicamente con funcionalidades de LLM

La salida token por token de un LLM es, por naturaleza, unidireccional y basada en texto — exactamente la forma para la que SSE fue diseñado. Además, normalmente no es crítica en latencia en el sentido de menos de 50 ms para el que existen WebSockets (que un token llegue 100 ms más tarde de lo teóricamente posible es imperceptible frente a los varios segundos que dura la generación completa); lo que importa es el volumen y la fiabilidad — muchos tokens, llegando en orden, a través de una conexión que sobrevive a un pequeño corte de red sin que falle todo el intercambio.

Polling: sigue siendo correcto a veces

El polling se descarta demasiado rápido. Para estados que cambian con poca frecuencia — el estado de finalización de un trabajo en segundo plano, la etapa de un pipeline de procesamiento de documentos — un cliente haciendo polling cada pocos segundos es más simple de construir, más simple de depurar y funciona a través de cualquier proxy, firewall y CDN sin configuración especial, porque no es más que HTTP simple repetido.

@router.get("/jobs/{job_id}/status")
async def get_job_status(job_id: str):
    job = await fetch_job(job_id)
    return {"status": job.status, "progress": job.progress}
const interval = setInterval(async () => {
  const res = await fetch(`/jobs/${jobId}/status`);
  const data = await res.json();
  updateProgressBar(data.progress);
  if (data.status === "complete") clearInterval(interval);
}, 2000);

El polling deja de ser suficiente una vez que la frecuencia de actualización necesaria es más rápida de lo razonable para hacer polling (subsegundo), o cuando el volumen de clientes haciendo polling convierte las solicitudes desperdiciadas de “nada ha cambiado” en un coste real — ambas son exactamente la situación de la salida token por token de un LLM, donde tendrías que hacer polling varias veces por segundo por usuario activo solo para aproximarte a lo que un stream te da gratis.

Server-Sent Events para salida token por token

SSE es HTTP simple con un tipo de contenido de respuesta específico (text/event-stream) y un formato de texto sencillo que la API nativa EventSource del navegador (o un bucle de lectura con fetch + ReadableStream) sabe analizar de forma incremental a medida que llegan bytes, en lugar de esperar a que la conexión se cierre:

data: {"token": "The"}

data: {"token": " quick"}

data: {"token": " brown"}

Cada línea data: (terminada por una línea en blanco) es un evento. Como no es más que HTTP, funciona a través de balanceadores de carga y CDNs estándar con la configuración de buffering adecuada, no requiere una actualización de protocolo como WebSockets, y la API integrada EventSource del navegador gestiona la reconexión automática con el last-event-id por ti — una funcionalidad que, de otro modo, tendrías que implementar manualmente para WebSockets.

FastAPI sirve SSE mediante StreamingResponse envolviendo un generador async — cada yield envía inmediatamente un fragmento por la conexión abierta:

from fastapi.responses import StreamingResponse
import json

async def token_stream(prompt: str):
    async for chunk in llm_client.stream(prompt):
        yield f"data: {json.dumps({'token': chunk})}\n\n"
    yield "data: [DONE]\n\n"

@router.get("/chat/stream")
async def chat_stream(prompt: str):
    return StreamingResponse(
        token_stream(prompt),
        media_type="text/event-stream",
        headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
    )

El async for del generador coopera de forma natural con el event loop — cada yield es un punto natural de suspensión, por lo que un proveedor de LLM lento no bloquea otras solicitudes como lo haría una llamada síncrona bloqueante (la misma cooperación con el event loop tratada en Por qué tu endpoint de FastAPI bloquea el event loop). La cabecera X-Accel-Buffering: no es un adelanto de la siguiente sección — sin ella, este endpoint puede parecer que funciona localmente y luego entregar su salida como un único bloque gigante en producción.

WebSockets: cuando realmente necesitas bidireccionalidad

WebSockets justifican su complejidad cuando el cliente necesita enviar datos de vuelta por la misma conexión abierta, en momentos arbitrarios, sin la sobrecarga de una nueva solicitud HTTP por mensaje — un editor colaborativo difundiendo pulsaciones, actualizaciones de posición en un juego multijugador, una funcionalidad de chat donde el cliente puede interrumpir o enviar mensajes de seguimiento a mitad del stream y el servidor necesita reaccionar inmediatamente.

from fastapi import WebSocket, WebSocketDisconnect

@router.websocket("/ws/chat")
async def chat_ws(websocket: WebSocket):
    await websocket.accept()
    try:
        while True:
            prompt = await websocket.receive_text()
            async for chunk in llm_client.stream(prompt):
                await websocket.send_json({"token": chunk})
            await websocket.send_json({"done": True})
    except WebSocketDisconnect:
        pass

Esto se parece al ejemplo de SSE, pero ahora la conexión sí admite realmente que el cliente envíe un nuevo prompt a mitad de la sesión sin abrir una nueva solicitud HTTP — esa es la capacidad real que se está comprando, al coste de necesitar tu propia lógica de reconexión (los navegadores no reconectan WebSockets automáticamente como sí hace EventSource), tu propio framing de mensajes si necesitas más estructura que texto/JSON sin procesar, e infraestructura que haga proxy correctamente del handshake de actualización de WebSocket, algo que no toda configuración de balanceador de carga o CDN soporta de forma predeterminada.

Para la mayoría de las funcionalidades de “transmitir una respuesta de LLM a una UI de chat”, el cliente en realidad no necesita enviar nada a mitad del stream — el siguiente mensaje del usuario es una nueva solicitud, no un mensaje por la misma conexión abierta — que es precisamente el caso donde SSE cubre el requisito con un riesgo de infraestructura significativamente menor.

Manejo de desconexiones y errores a mitad del stream

Una respuesta en streaming tiene una superficie de fallo más larga que una solicitud/respuesta normal — el cliente puede abandonar la página a mitad del stream, el proveedor de LLM puede fallar después de enviar una respuesta parcial, y ambos casos necesitan manejo explícito o terminarás con trabajo de generación huérfano o con un stream que se queda colgado en lugar de fallar limpiamente.

Detectar desconexiones del cliente. El objeto Request de FastAPI expone is_disconnected(), que vale la pena comprobar dentro de un generador de larga duración para dejar de hacer (y pagar) trabajo que nadie va a recibir:

async def token_stream(request: Request, prompt: str):
    async for chunk in llm_client.stream(prompt):
        if await request.is_disconnected():
            break  # el cliente abandonó la página — detener la generación
        yield f"data: {json.dumps({'token': chunk})}\n\n"

Sin esta comprobación, una pestaña abandonada del navegador no detiene la llamada subyacente al LLM — sigues pagando y generando tokens que nadie verá jamás, algo que se acumula rápidamente con cualquier volumen de tráfico real.

Exponer errores a mitad del stream al cliente. Una vez que se ha enviado el primer token, ya no puedes cambiar el código de estado HTTP — la respuesta ya ha comenzado. Los errores que ocurren en mitad de la generación deben enviarse como un evento de datos que el frontend compruebe explícitamente, en lugar de confiar en un estado de error HTTP:

async def token_stream(prompt: str):
    try:
        async for chunk in llm_client.stream(prompt):
            yield f"data: {json.dumps({'token': chunk})}\n\n"
    except LLMProviderError as exc:
        yield f"data: {json.dumps({'error': str(exc)})}\n\n"
    finally:
        yield "data: [DONE]\n\n"

Entonces, el frontend necesita comprobar cada evento entrante en busca de una clave error en lugar de asumir que cada evento es un token — un detalle fácil de omitir en una primera implementación y que solo sale a la luz la primera vez que el proveedor de LLM falla realmente a mitad de respuesta en producción.

Estructurar endpoints de streaming en una aplicación más grande

Las rutas de streaming tienden a acumular sus propias preocupaciones — lógica de reintentos específica del proveedor, construcción de prompts, manejo de desconexiones — que no pertenecen mezcladas en un router junto a endpoints CRUD ordinarios. En una disposición guiada por dominios como la tratada en Estructura de proyecto FastAPI que sobrevive al crecimiento, un dominio chat o completions con su propio router.py, service.py y un streaming.py dedicado para las funciones generadoras mantiene esta lógica aislada y comprobable independientemente del transporte — el mismo generador token_stream puede ser utilizado por una ruta SSE, un manejador WebSocket o una prueba que simplemente lo itere directamente, sin duplicar tres veces la lógica de llamada al LLM en cada punto de entrada.

El marco de decisión

SeñalPollingSSEWebSockets
Dirección de los datosSolicitud/respuestaSolo servidor → clienteBidireccional
Frecuencia de actualizaciónSegundos+Subsegundo, alto volumenSubsegundo, alto volumen
¿El cliente envía durante el stream?N/ANo
Funciona a través de proxies HTTP estándarSiempreNormalmente, con configuraciónNecesita soporte de upgrade
Reconexión integradaN/A (sin estado)Sí (EventSource)No — constrúyela tú
Complejidad de infraestructuraLa más bajaBaja-mediaMás alta
Encaja con streaming de tokens de LLMMal (demasiado tosco)BienExcesivo salvo que sea bidireccional

Si el cliente nunca necesita enviar datos a mitad de la conexión, empieza con SSE — es la forma que realmente tiene el streaming de LLM, y el coste operativo es menor. Recurre a WebSockets específicamente cuando la interacción sea genuinamente bidireccional en tiempo real, no porque suene más sofisticado para una funcionalidad que en realidad es unidireccional.

Problemas de buffering y proxy que rompen el streaming

Aquí es donde realmente fallan los despliegues con SSE, y casi nunca es en el código de FastAPI — es en lo que sea que se encuentre entre Uvicorn y el cliente.

Buffering de respuestas en nginx. Por defecto, nginx almacena en búfer las respuestas proxificadas antes de enviarlas al cliente, lo que anula por completo el streaming — el cliente recibe toda la respuesta de una vez, cuando termina la llamada al LLM, no de forma incremental. La solución es desactivar el buffering específicamente para la ruta de streaming:

location /chat/stream {
    proxy_pass http://fastapi_upstream;
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;
}

La cabecera de respuesta X-Accel-Buffering: no mostrada en el ejemplo de FastAPI anterior es específica de nginx y consigue el mismo efecto sin tocar directamente la configuración de nginx — útil cuando no controlas la configuración del proxy pero sí las cabeceras de respuesta de la aplicación.

Middleware de gzip/compresión. La compresión de respuestas necesita almacenar en búfer toda la salida para comprimirla eficazmente, lo cual está directamente en conflicto con la entrega incremental. Excluye las rutas de streaming de cualquier GZipMiddleware o configuración de compresión del proxy inverso — comprimir individualmente un stream de pequeños fragmentos SSE también añade sobrecarga con poco beneficio, dada la pequeñez típica de cada token.

Tiempos de espera por inactividad del balanceador de carga. Un balanceador de carga gestionado (un AWS ALB, por ejemplo) tiene un tiempo de espera por inactividad predeterminado — a menudo 60 segundos — que cerrará una conexión si no se envían nuevos bytes durante ese tiempo. Una respuesta lenta del LLM, o una pausa entre tokens más larga que el tiempo de espera, mata silenciosamente el stream. O bien envías periódicamente líneas de comentario SSE (: keep-alive\n\n) para mantener la conexión activa, o elevas el tiempo de espera por inactividad del balanceador específicamente para las rutas de streaming.

Cantidad de workers de Uvicorn y conexiones largas. Cada conexión SSE o WebSocket abierta ocupa la atención de un worker durante toda su duración. Ejecutar Uvicorn con muy pocos workers en relación con las conexiones concurrentes de streaming significa que nuevas solicitudes no relacionadas quedan en cola detrás de streams de larga duración — dimensiona la cantidad de workers (o pasa a un gestor de procesos como Gunicorn con la clase de worker de Uvicorn) teniendo en cuenta tu número esperado de streams concurrentes, no solo tu tasa media de solicitudes.

Buffering del lado del cliente. Algunos navegadores y clientes HTTP almacenan en búfer pequeños fragmentos de respuesta antes de entregarlos a JavaScript. Enviar un tamaño mínimo de fragmento (algunas implementaciones rellenan los primeros eventos) o vaciar explícitamente después de cada yield evita que los primeros tokens parezcan “atascarse” antes de que el stream empiece visiblemente.

Ejemplos de código

Un endpoint SSE completo con pings keep-alive para sobrevivir a los tiempos de espera por inactividad del balanceador de carga, y un consumidor de frontend correspondiente:

import asyncio
import json

async def token_stream(prompt: str):
    last_sent = asyncio.get_event_loop().time()
    async for chunk in llm_client.stream(prompt):
        yield f"data: {json.dumps({'token': chunk})}\n\n"
        last_sent = asyncio.get_event_loop().time()
    yield "data: [DONE]\n\n"

@router.get("/chat/stream")
async def chat_stream(prompt: str):
    return StreamingResponse(
        token_stream(prompt),
        media_type="text/event-stream",
        headers={
            "X-Accel-Buffering": "no",
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
        },
    )
const evtSource = new EventSource(`/chat/stream?prompt=${encodeURIComponent(prompt)}`);
evtSource.onmessage = (event) => {
  if (event.data === "[DONE]") { evtSource.close(); return; }
  const { token } = JSON.parse(event.data);
  appendToken(token);
};

Para una necesidad genuinamente bidireccional — que el cliente interrumpa la generación a mitad del stream — el manejador WebSocket mostrado antes se amplía de forma natural comprobando concurrentemente la llegada de un mensaje “stop” junto con el bucle de generación, usando asyncio.wait tanto sobre la corrutina de recepción como sobre la corrutina de generación para que cualquiera de las dos pueda completarse primero.

Errores comunes

Error: recurrir por defecto a WebSockets para streaming de LLM. La mayoría de las funcionalidades tipo chat son unidireccionales por turno y no necesitan la infraestructura añadida. Solución: usa SSE por defecto a menos que el cliente realmente necesite enviar datos por la misma conexión abierta a mitad del stream.

Error: no desactivar el buffering del proxy. Esta es la causa más común de “el streaming funciona localmente, pero llega como un solo bloque en producción”. Solución: desactiva explícitamente el buffering (proxy_buffering off, X-Accel-Buffering: no) para cada ruta de streaming, y verifícalo con un proxy real en staging, no solo contra un proceso Uvicorn desnudo.

Error: olvidar los keep-alives frente a los tiempos de espera por inactividad del balanceador de carga. Un stream que se pausa más tiempo que el timeout de inactividad del LB se mata silenciosamente a mitad de la respuesta. Solución: envía pings periódicos con líneas de comentario, y conoce explícitamente el valor del timeout de inactividad de tu balanceador en lugar de asumir que es generoso.

Error: comprimir una respuesta en streaming. El middleware de compresión almacena en búfer para hacer bien su trabajo, anulando la entrega incremental. Solución: excluye por completo las rutas de streaming del middleware gzip/Brotli.

Error: infraaprovisionar workers para conexiones largas. Cada stream abierto ocupa un worker durante toda su duración; muy pocos workers significa que nuevas solicitudes quedan en cola detrás de streams activos. Solución: dimensiona la cantidad de workers según las conexiones de streaming concurrentes esperadas, no según el rendimiento promedio de solicitudes.

Buenas prácticas de producción

  • Usa SSE por defecto para streaming unidireccional, y reserva WebSockets para interacción genuinamente bidireccional y de baja latencia.
  • Prueba el streaming detrás de tu pila real de proxies de producción en staging, no solo contra un proceso Uvicorn desnudo — los errores de buffering son invisibles hasta que hay un proxy en el camino.
  • Desactiva explícitamente el buffering y la compresión en las rutas de streaming, tanto en la configuración del proxy inverso como mediante cabeceras de respuesta.
  • Envía pings keep-alive en streams largos para sobrevivir a los tiempos de espera por inactividad del balanceador de carga y de proxies intermedios.
  • Dimensiona la cantidad de workers de Uvicorn/Gunicorn para streams abiertos concurrentes, no solo para solicitudes por segundo — una conexión mantenida durante mucho tiempo tiene un coste de capacidad distinto al de un viaje de ida y vuelta rápido.

Cierre

El transporte de streaming correcto viene determinado por la forma real de tu flujo de datos, no por cuál suene más impresionante en un documento de arquitectura — y para la inmensa mayoría de las funcionalidades de streaming de tokens de LLM, esa forma es unidireccional, que es exactamente para lo que SSE fue creado a una fracción del coste de infraestructura de WebSockets. El código que transmite tokens correctamente suele ser la parte fácil; la configuración del proxy y del balanceador de carga que se interpone entre tu aplicación FastAPI y el navegador es donde el streaming en producción realmente se rompe, así que pruébalo ahí antes de confiar en él.

¿Tu endpoint de streaming se ha probado realmente detrás de tu proxy de producción, o solo contra un servidor de desarrollo Uvicorn desnudo?

Más artículos