Streaming SSE con Hono y Bun: la API de tu agente de IA
El endpoint funcionaba. El agente respondía. El usuario veía una ruleta girando veintidós segundos y luego, de golpe, un muro de texto.
Lo peor no fue la espera: abrió la misma pregunta en tres pestañas porque creyó que se había colgado. Tres ejecuciones del agente, tres facturas de tokens, una respuesta leída.
Lo reescribí usando streaming SSE con Hono y Bun. Y el arreglo de fondo no fue técnico, fue conceptual: yo devolvía la respuesta de un agente como si fuera un JSON. Un agente no devuelve un resultado. Un agente transcurre. Piensa, llama a una herramienta, se equivoca, reintenta.
Si tu API no transmite ese transcurso, el usuario solo ve una ruleta y saca sus propias conclusiones.
Para exponer un agente por HTTP con salida en tiempo real, usa el helper streamSSE de hono/streaming sobre Bun: emite eventos con nombre (token, tool_call, error, done) en lugar de texto plano, propaga la desconexión del cliente a un AbortSignal con stream.onAbort() para dejar de gastar tokens, y manda un comentario SSE (: ping) cada 15-20 segundos para que ningún proxy corte la conexión. Todo el código de este post está verificado ejecutándolo contra Hono 4.13.7 sobre Bun 1.3.
SSE no está deprecado: lo que se deprecó fue el transporte HTTP+SSE de MCP
Son dos capas distintas y solo se deprecó una. Si vienes de mi post sobre montar un MCP Server en producción con Streamable HTTP y auth, su primer titular dice que SSE está deprecado, y ahora te propongo construir una API con SSE. No hay contradicción.
Lo que se deprecó es el transporte HTTP+SSE del protocolo MCP —el de dos endpoints, uno GET para abrir el canal y otro POST para enviar, definido en la revisión 2024-11-05—, sustituido por Streamable HTTP en la 2025-03-26 y reclasificado formalmente como Deprecated en la 2026-07-28. Eso decide cómo hablan entre sí un cliente MCP y un servidor MCP.
Server-Sent Events, el mecanismo del navegador, no está deprecado en absoluto. La especificación vigente de MCP —revisión 2026-07-28— sigue construida sobre él: el servidor responde a cada petición con un único objeto JSON o con un stream de Server-Sent Events, y el cliente está obligado a aceptar text/event-stream. En el registro oficial de features deprecadas la única entrada de transporte sigue siendo HTTP+SSE transport, deprecado en 2025-03-26, con Streamable HTTP como ruta de migración. Cambió la coreografía de endpoints, no el formato del stream. Aquí no implementamos MCP: construimos tu propia API para tu propio frontend.
Por qué SSE y no WebSockets para un agente
Porque el flujo de un agente es unidireccional: el usuario manda una pregunta y luego solo escucha. Abrir un canal bidireccional para eso es pagar complejidad por una dirección que nunca usas.
Server-Sent Events (SSE) es el estándar web que permite a un servidor enviar un flujo de mensajes al cliente sobre una única conexión HTTP abierta, en texto plano y con el formato event: / data: / id:. Es unidireccional por diseño: el cliente abre la conexión y a partir de ahí solo recibe.
La diferencia práctica está en lo que tienes que operar después del primer despliegue.
| SSE | WebSockets | |
|---|---|---|
| Dirección | Servidor → cliente | Bidireccional |
| Protocolo | HTTP normal, respuesta larga | Upgrade a ws:// |
| Proxies, CDN y balanceadores | Pasa como cualquier respuesta HTTP | Necesitan soporte explícito de upgrade |
| Reconexión | Automática en el navegador, con Last-Event-ID |
La implementas tú |
| Autenticación | Tus cookies o headers de siempre (con fetch) |
Handshake aparte, token en query |
| Estado en el servidor | Ninguno: es una request más | Conexiones vivas que gestionar |
| Depuración | curl -N y lo lees |
Herramienta específica |
Elige WebSockets cuando el cliente tenga que interrumpir, corregir o hablar durante la generación: audio en vivo, edición colaborativa. Para un chat de agente con herramientas, SSE gana por aburrimiento operativo.
Streaming SSE con Hono y Bun: el endpoint en veinte líneas
El helper vive en hono/streaming y su firma es streamSSE(c, callback, onError?). Dentro del callback recibes un objeto de stream y escribes eventos con writeSSE().
import { Hono } from 39;hono39;
import { streamSSE } from 39;hono/streaming39;
const app = new Hono()
app.post(39;/agent39;, (c) =>
streamSSE(c, async (stream) => {
await stream.writeSSE({
event: 39;tool_call39;,
data: JSON.stringify({ name: 39;search_docs39; }),
id: 39;139;,
})
await stream.writeSSE({ event: 39;token39;, data: JSON.stringify({ text: 39;Hola39; }), id: 39;239; })
await stream.writeSSE({ event: 39;done39;, data: 39;{}39; })
})
)
export default { port: 3000, fetch: app.fetch }
Ese export default { port, fetch } no es de Hono: es el contrato de Bun.serve. Bun arranca el servidor con bun run index.ts, sin adaptador ni servidor HTTP intermedio, y empuja cada chunk al socket según lo produces — que es justo lo que necesita un stream.
El objeto que acepta writeSSE es { data, event?, id?, retry? }, con data como string o Promise<string>. Hono pone por ti Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive y Transfer-Encoding: chunked. Lo tienes documentado en el streaming helper de Hono.
Lo que sale por el cable, verificado con curl -N, es exactamente esto:
event: tool_call
data: {"name":"search_docs"}
id: 1
event: token
data: {"text":"Hola"}
id: 2
event: done
data: {}
Hono encaja aquí porque es un router sobre Web Standards: no te obliga a envolver la respuesta en abstracciones propias, y eso importa cuando lo que devuelves es un stream y no un objeto. La comparativa completa está en Hono vs NestJS vs Express.
Si tu stack es NestJS, el mismo problema se resuelve de otra forma y lo cubrí aparte en streaming de respuestas de IA con NestJS y el Vercel AI SDK: allí el SDK gestiona el protocolo por ti sobre la Response nativa. Aquí el contrato de eventos lo defines tú, que es justo lo que quiero que controles.
Eventos con significado, no un chorro de texto
El error que veo en casi todas las implementaciones: mandar solo data: <trozo de texto> y que el cliente concatene. Con eso el frontend no puede renderizar estados, solo puede pintar letras.
Un agente tiene fases visibles para el usuario. Dale un nombre a cada una.
event |
data (JSON) |
Qué hace el cliente |
|---|---|---|
token |
{"text":"…"} |
Concatena en la burbuja de respuesta |
tool_call |
{"name":"search_docs","args":{…}} |
Muestra "Buscando en la documentación…" |
tool_result |
{"name":"search_docs","ok":true,"ms":412} |
Cierra el indicador de herramienta |
error |
{"code":"RATE_LIMIT","message":"…"} |
Pinta el fallo y ofrece reintentar |
done |
{"usage":{"input":812,"output":344}} |
Cierra el stream y guarda la conversación |
Ese contrato es una API pública aunque viva dentro de tu repo. Si mañana renombras tool_call a toolCall, rompes el frontend en producción sin que ningún compilador te avise: entre servidor y cliente solo viaja texto.
Por eso defino el contrato como un discriminated union validado con Zod y lo importo en los dos lados. El servidor lo usa para serializar, el cliente para parsear. Si un evento no encaja con el schema, lo descartas y lo registras en lugar de romper el render. Es el patrón que enseño en el curso de Zod para validación y transformación de datos en TypeScript, aplicado al borde más frágil de una app de IA.
Un detalle del formato: writeSSE parte tu data por saltos de línea y emite una línea data: por cada trozo. Con JSON.stringify no te afecta, porque produce una sola línea. Con texto crudo multilínea, sí.
Cancelación: el usuario cierra la pestaña y tú sigues pagando
Cuando el cliente se desconecta, Hono marca stream.aborted = true y dispara los listeners registrados con stream.onAbort(). Ese es el enganche para abortar el trabajo del agente.
Aquí está el detalle que casi nadie cuenta, y lo verifiqué ejecutándolo: escribir en un stream muerto no lanza ninguna excepción. El write interno de Hono captura el error y sigue como si nada. Si tu bucle espera un try/catch para enterarse de la desconexión, va a seguir llamando al modelo hasta terminar la respuesta entera. Y la vas a pagar.
app.post(39;/agent39;, (c) =>
streamSSE(c, async (stream) => {
const ac = new AbortController()
stream.onAbort(() => ac.abort()) // el cliente se fue: corta el trabajo
const agent = runAgent({ prompt: await c.req.json(), signal: ac.signal })
for await (const chunk of agent) {
if (stream.aborted) return // guardia explícita: no confíes en que write falle
await stream.writeSSE({ event: 39;token39;, data: JSON.stringify({ text: chunk }) })
}
await stream.writeSSE({ event: 39;done39;, data: 39;{}39; })
})
)
Dos mecanismos, y quieres los dos. onAbort propaga la cancelación hacia abajo —al SDK del modelo, a tu fetch de herramientas, a la query de base de datos— porque casi todo el ecosistema acepta un AbortSignal. La guardia if (stream.aborted) return corta el bucle en el siguiente ciclo aunque la librería de turno ignore la señal.
En mi prueba, con un cliente que abortaba a mitad de stream, onAbort se disparó en el mismo tick en que el bucle vio aborted = true, y el AbortSignal del agente quedó abortado.
Hay un detalle propio de Bun que conviene conocer: onAbort depende de que el runtime cancele el ReadableStream de la respuesta, y Hono todavía arrastra una función isOldBunVersion() que considera antigua cualquier versión que empiece por 1.1, 1.0 o 0.. En esas escucha c.req.raw.signal para abortar el stream a mano. De Bun 1.2 en adelante funciona el camino nativo y no tienes que hacer nada.
Esto es la contrapartida natural del agentic loop en producción con TypeScript: allí pones el techo de pasos para que el agente no se dispare solo, aquí pones el interruptor para que no siga corriendo cuando ya no hay nadie escuchando.
Heartbeats: por qué tu stream muere a los sesenta segundos
Porque los proxies inversos, los balanceadores y los CDN cierran conexiones que llevan demasiado tiempo sin transmitir bytes. En Nginx son los 60 segundos de proxy_read_timeout, su valor por defecto. Un agente pensando o esperando a una herramienta lenta produce exactamente ese silencio.
La solución cabe en una línea. SSE define que toda línea que empieza por : es un comentario y el cliente la ignora:
const beat = setInterval(() => {
if (!stream.aborted) void stream.write(39;: ping\n\n39;)
}, 15_000)
stream.onAbort(() => clearInterval(beat))
// y clearInterval(beat) también al terminar bien
Y hay una segunda mitad que casi nadie configura: aunque mandes el heartbeat perfecto, un proxy con buffering activo va acumulando los eventos y entregándolos a golpes, así que el usuario sigue sin ver nada en tiempo real. Se desactiva con una cabecera, y la propia especificación de MCP la recomienda: los servidores SHOULD incluir X-Accel-Buffering: no al abrir un stream SSE, porque sin ella "los proxies pueden acumular mensajes antes de enviarlos al cliente".
c.header(39;X-Accel-Buffering39;, 39;no39;)
Verificado en el cable: el ping viaja, no genera ningún evento en el cliente y mantiene la conexión con tráfico. Elige un intervalo por debajo del timeout de tu proxy: 15 segundos es seguro contra los 60 de proxy_read_timeout. En PaaS el corte llega antes y no lo decides tú — los timeouts de Render, Railway y Fly los desgloso en desplegar agentes LangChain en producción.
Aprovecha también retry: al emitir { data: '…', retry: 3000 } le dices al navegador cuánto esperar antes de reconectar. Y si numeras los eventos con id, el navegador reenvía el último en la cabecera Last-Event-ID al reconectar, así que puedes reanudar en vez de empezar de cero. Eso solo aplica cuando el cliente es EventSource, y ahí viene el siguiente problema.
El cliente: por qué EventSource se te queda corto
Porque EventSource solo hace peticiones GET y no admite body ni headers personalizados. Para un agente necesitas mandar el prompt, el historial y un Authorization: o metes la conversación entera en la query string, o cambias de herramienta.
Cambias de herramienta. fetch con un lector de stream y un parser de veinte líneas:
async function* readSSE(res: Response) {
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = 39;39;
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let sep: number
while ((sep = buffer.indexOf(39;\n\n39;)) !== -1) {
const raw = buffer.slice(0, sep)
buffer = buffer.slice(sep + 2)
let event = 39;message39;
let id: string | undefined
const data: string[] = []
for (const line of raw.split(39;\n39;)) {
if (line.startsWith(39;:39;)) continue // heartbeat
if (line.startsWith(39;event:39;)) event = line.slice(6).trim()
else if (line.startsWith(39;data:39;)) data.push(line.slice(5).replace(/^ /, 39;39;))
else if (line.startsWith(39;id:39;)) id = line.slice(3).trim()
}
if (data.length) yield { event, id, data: data.join(39;\n39;) }
}
}
}
Dos cosas se rompen si las improvisas. Los eventos llegan agrupados o partidos: en mi prueba el primer chunk traía dos eventos completos juntos, así que hay que bufferear y cortar por línea en blanco, nunca asumir un chunk igual a un evento. Y decoder.decode(value, { stream: true }) no es opcional: sin ese flag, un carácter multibyte partido entre dos chunks llega corrupto. En español eso es cualquier acento.
El precio de dejar EventSource es que pierdes la reconexión automática y el Last-Event-ID. Si los necesitas, los implementas tú guardando el último id recibido y reenviándolo al reintentar. Cancelar, en cambio, es trivial: pasa un AbortController al fetch y llama a abort() cuando el usuario pulse "parar" o el componente se desmonte. Eso dispara todo el camino de cancelación de la sección anterior.
El error a mitad de stream: ya enviaste un 200 OK
Cuando el agente falla en el segundo 12, las cabeceras salieron hace 12 segundos. No hay un 500 que devolver. El fallo tiene que viajar dentro del stream, como un evento más.
Hono lo contempla con el tercer argumento de streamSSE:
app.post(39;/agent39;, (c) =>
streamSSE(
c,
async (stream) => {
// ...el agente...
},
async (err, stream) => {
logger.error({ err }, 39;agent stream failed39;)
await stream.writeSSE({
event: 39;error39;,
data: JSON.stringify({ code: 39;AGENT_FAILED39;, message: 39;No he podido completar la respuesta.39; }),
})
}
)
)
Dos avisos que solo se descubren mirando la respuesta cruda, y los comprobé.
El primero: si pasas el tercer argumento a streamSSE, además de tu handler Hono emite automáticamente su propio evento error con el message de la excepción en crudo. Tu cliente recibirá dos eventos error por un solo fallo. Trátalo: quédate con el primero y descarta el resto hasta el cierre. Sin onError, en cambio, Hono no manda nada al cliente y la excepción se queda en un console.error del servidor.
El segundo es de seguridad. Ese mensaje automático es el texto real de la excepción y va tal cual al navegador. Si tu error trae una URL interna, un nombre de tabla o un fragmento de credencial, acabas de filtrarlo. Lanza errores con mensajes ya saneados, o envuelve el cuerpo del handler en tu propio try/catch y nunca dejes que la excepción llegue al helper.
Revisar este tipo de detalle en el código que genera un agente es lo que trabajo en el ebook gratuito Revisión por Contrato: un modelo te escribe este endpoint en treinta segundos, te devuelve el camino feliz impecable y te deja estos dos fallos intactos.
Qué puedes montar hoy
Coge tu endpoint de agente actual, el que devuelve un JSON al final, y cámbiale tres cosas: envuélvelo en streamSSE, emite token / tool_call / done en vez de un objeto final, y engancha stream.onAbort() a un AbortController que pases hacia abajo.
Con eso dejas de pagar respuestas que nadie lee. El resto —heartbeats, reconexión, validación con Zod— lo añades cuando el primero se sostenga.
Si quieres el flujo completo de idea a producto construyendo con agentes, lo enseño paso a paso en el curso Construye con IA.
Preguntas frecuentes
¿SSE está deprecado en 2026?
No. Lo que se deprecó fue el transporte HTTP+SSE del protocolo MCP, sustituido por Streamable HTTP en la revisión 2025-03-26. Server-Sent Events como mecanismo web sigue vigente y es estándar; en la revisión vigente 2026-07-28 Streamable HTTP lo sigue usando para la parte de streaming, respondiendo con Content-Type: text/event-stream. Son capas distintas: una es la coreografía de endpoints de MCP, otra es el formato del stream.
¿Cómo detecto en Hono que el cliente cerró la pestaña?
Con stream.onAbort(callback) para reaccionar, y con la propiedad stream.aborted para comprobarlo dentro de tu bucle. Lo importante es no confiar en que la escritura falle: el write de Hono captura el error internamente y no lanza nada, así que un bucle sin la guardia if (stream.aborted) seguirá llamando al modelo y generando coste después de que el usuario se haya ido.
¿Puedo usar EventSource para llamar a mi endpoint de agente?
Solo si tu endpoint es GET y no necesitas headers personalizados, porque EventSource no admite ni body ni Authorization. Para un agente al que le mandas prompt e historial, lo práctico es fetch con un parser propio del stream. Pierdes la reconexión automática y el manejo de Last-Event-ID, y si los necesitas los implementas tú guardando el último id recibido.
¿Cada cuánto debo mandar un heartbeat en un stream SSE?
Cada 15 o 20 segundos, siempre por debajo del timeout de inactividad de tu proxy o balanceador — 60 segundos es el valor típico de Nginx. Se envía como un comentario SSE: una línea que empieza por dos puntos seguida de una línea en blanco, que el cliente ignora sin generar ningún evento. Recuerda limpiar el setInterval tanto al terminar bien como en onAbort.
¿Cómo devuelvo un error si ya envié las cabeceras con 200 OK?
Emitiendo un evento error dentro del propio stream, porque el código de estado ya viajó. En Hono usas el tercer argumento de streamSSE. Ten en cuenta que Hono añade además su propio evento error con el mensaje crudo de la excepción, así que tu cliente recibirá dos, y conviene sanear los mensajes que lanzas para no filtrar detalles internos.
¿SSE o WebSockets para una app de chat con IA?
SSE, salvo que el cliente necesite hablar durante la generación. El flujo de un chat con agente es una pregunta y luego solo escuchar, y SSE viaja sobre HTTP normal: atraviesa proxies y CDN sin configuración especial, reutiliza tu autenticación y no deja estado de conexión que gestionar. WebSockets compensa cuando hay audio bidireccional o interrupciones en vivo.
Si quieres ver este endpoint construido en directo, con el agente conectado y midiendo la cancelación en tiempo real, lo publico en el canal de YouTube de Dominicode.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
