Por qué corremos Bun en el contenedor del agente (y Node en el host)
Cuando diseñas un sistema que orquesta contenedores por sesión, cada milisegundo de wake-up se nota. Si el usuario manda un mensaje y tarda un segundo en llegar al modelo, eso es UX. Si tarda medio segundo más porque hay un tsc corriendo antes de que el runtime arranque, ese medio segundo es puro peaje técnico que el usuario nunca debería pagar.
Este post documenta una decisión arquitectónica que tomamos en Ghosty v2: correr Bun dentro del contenedor del agente, mientras el host sigue en Node + pnpm. Un solo stack era más simple. Pero "simple" no siempre significa "correcto".
El setup: host orquestador, contenedor por sesión
El patrón es común en sistemas de agentes: un proceso host de larga vida que recibe mensajes de múltiples canales (WhatsApp, Slack, Telegram, etc.) y, por cada sesión activa, levanta un contenedor aislado donde corre el agente. Host y contenedor no comparten memoria — se comunican vía dos archivos SQLite por sesión:
session_id/
inbound.db — host escribe, contenedor lee
outbound.db — contenedor escribe, host leeExactamente un escritor por archivo. Cero contención de locks entre mounts. Ese aislamiento es lo que permite usar runtimes distintos en cada lado sin acoplarlos.
El stack original: Node + npm en todos lados
La primera versión era uniforme. Node 22 + npm en el host, Node 22 + npm en el contenedor. Mismo lockfile, mismas dependencias, mismo tsc compilando TypeScript a dist/ durante el build de imagen.
Los problemas aparecieron en producción:
better-sqlite3recompila en cada rebuild de imagen. Es una extensión nativa. Cada vez que invalidabas la capa, 30-60 segundos denode-gypcontra headers de Node, libc, V8. Frustrante en CI, peor localmente cuando iteras.- El
tscdel build sumaba 200-500ms al wake de sesión. Porque aunque compilábamos adist/, el host mounteaba fresh source sobre/app/srcpara que los edits del operador se aplicaran sin rebuild. Si mounteas source, necesitas compilar o interpretar al arrancar. Compilar en cada wake no escala. npm installen el contenedor era lento. No es catastrófico, pero cuando iteras en deps del agente lo sientes.
Ninguno era un bug, pero sumados eran fricción constante.
La alternativa: Bun en el contenedor
Bun resuelve estos tres problemas de una sentada:
bun:sqliteviene built-in. No hay módulo nativo que compilar. La capa de Docker con deps es instantánea.- Corre TypeScript directo, sin
tsc.bun run src/index.tsejecuta el archivo sin paso de build. Borrar eltscdel wake le quitó ~300ms en promedio al arranque de sesión. bun installes 5-10× más rápido quenpm install. Cuando bumpeas el SDK del agente, lo notas.
La pregunta obvia: ¿y por qué no pasar el host a Bun también?
Por qué el host se queda en Node
Hay una librería en el host que decide esto: Baileys (WhatsApp). Baileys depende de libsignal-node vía bindings nativos, más un stack WebSocket/HTTP bastante probado sobre Node. La compat Node-API de Bun ha mejorado muchísimo, pero Baileys es justamente el tipo de dependencia donde un edge case te arruina el fin de semana: reconexiones, signal keys, binary frames de WebSocket.
El contenedor del agente, en cambio, no tiene deps nativas. Su vida es: leer de inbound.db, llamar al SDK del modelo, escribir a outbound.db. Bun puede hacer eso perfecto sin tocar bindings nativos.
Así que el criterio terminó siendo:
Usa Bun donde te beneficia (velocidad de install, sin native compile, sin tsc de wake) y deja Node donde ya funciona con deps que te importa que sigan funcionando.
Lo que se complica con el split
Dos runtimes no es gratis. Estas son las cosas que tuvimos que arreglar y documentar:
1. Dos lockfiles, dos package managers
/ pnpm 10 + Node 22
pnpm-lock.yaml
pnpm-workspace.yaml ← minimumReleaseAge, onlyBuiltDependencies
/container/agent-runner/ Bun 1.3+
bun.lock
package.jsonAmbos committeados. CI corre --frozen-lockfile en los dos, así que cualquier drift entre package.json y lockfile rompe el build. El mental model es: son dos paquetes, no un monorepo.
2. Supply chain asimétrica
En el host usamos minimumReleaseAge: 4320 (3 días de espera antes de resolver una versión nueva) y un allowlist de onlyBuiltDependencies para scripts de postinstall. Es la política pnpm contra ataques tipo chalk/shaiHulud/compromised-maintainer.
Bun no tiene hoy un equivalente a minimumReleaseAge. La defensa en el contenedor es:
bun.lockpinned- Versiones de Bun, Claude Code, Vercel y demás CLIs pinned vía
ARGen el Dockerfile - Cuando bumpeas
@anthropic-ai/claude-agent-sdkrevisas fecha de release en npm y lo haces deliberadamente, nunca conbun update
Los CLIs globales que el agente invoca en runtime (claude-code, vercel, etc.) siguen instalándose con pnpm install -g en el Dockerfile para no saltarse la política. bun install -g rompería eso.
3. bun:sqlite no auto-strip de named params
Este fue mi favorito descubrir. En better-sqlite3 puedes hacer:
db.prepare("INSERT INTO msgs (id, body) VALUES ($id, $body)")
.run({ id: "abc", body: "hola" }); // auto-strip del $En bun:sqlite eso falla silenciosamente. Hay que mandar el prefijo en las keys del objeto:
db.prepare("INSERT INTO msgs (id, body) VALUES ($id, $body)")
.run({ $id: "abc", $body: "hola" }); // ← el $ es literalLos params posicionales con ? funcionan igual en ambos. La trampa es solo con named. Documentado con neón en el doc de build-and-runtime porque el síntoma es un undefined raro en la columna, no una excepción.
4. Tests distintos
Los tests del host corren con vitest. Los del contenedor con bun test. No son intercambiables: vitest corre sobre Node y no puede cargar bun:sqlite. vitest.config.ts excluye container/agent-runner/ explícitamente.
El CI corre ambos en secuencia:
- pnpm install --frozen-lockfile
- bun install --frozen-lockfile # cwd: container/agent-runner
- pnpm exec tsc --noEmit
- pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
- pnpm exec vitest run
- bun test # cwd: container/agent-runnerCualquiera que falle, falla el PR.
5. Una invariante crítica: journal_mode=DELETE
Las DBs de sesión no pueden usar WAL (journal_mode=WAL). Por qué: WAL usa un archivo -shm mapeado en memoria, y ese memory-map no cruza VirtioFS entre host y guest del contenedor. El host escribiría en WAL, el contenedor leería stale y viceversa.
Con journal_mode=DELETE cada commit hace fsync completo. Es más lento que WAL en absoluto, pero "lento" aquí son ~1ms por transacción y nuestras transacciones son pequeñas. No es el cuello de botella.
Resultado
| Métrica | Node+npm uniforme | Split Node/Bun |
|---|---|---|
| Wake de sesión | ~700-900ms | ~400-500ms |
| Rebuild de imagen (deps change) | 45-90s | 10-20s |
| Rebuild de imagen (source change) | 15-30s | 3-5s |
pnpm install host | ~25s | ~25s (igual) |
install del agent-runner | ~40s (npm) | ~5s (bun) |
Los números son de nuestro hardware y varían, pero el orden de magnitud se mantiene. Lo importante es que el wake de sesión bajó a casi la mitad, y ese es el número que ve el usuario final.
Cuándo vale la pena un split así
No siempre. Si tu app es un solo proceso que habla con una base de datos compartida, split de runtimes es overkill — gestionar dos lockfiles, dos policies de supply chain, dos sistemas de test es costo real.
Vale la pena cuando:
- El IO entre procesos ya es explícito y aislado. En nuestro caso son los dos SQLite files por sesión. Si host y contenedor compartieran módulos JS, forget it.
- El lado "nuevo runtime" tiene una superficie de deps controlada. El contenedor del agente tiene ~15 deps runtime. El host tiene Baileys y compañía — cientos.
- El beneficio es medible. 300-400ms de wake es notable. 20ms no lo sería.
Si estás en ese perfil, el split te compra velocidad real sin sacrificar la confiabilidad en los lugares donde la necesitas.
Conclusiones
La lección general no es "Bun es más rápido" — es elegir el runtime por pieza, no por proyecto. Node en el host porque Baileys y el ecosistema maduro pesan más que la velocidad. Bun en el contenedor porque SQLite built-in + TS directo + install rápido pesan más que la uniformidad.
El precio es tener dos lockfiles, dos package managers, dos policies de supply chain y un par de gotchas como el $ literal de bun:sqlite. Bien documentado, es manejable. Invisible para el usuario, que solo nota que el agente responde más rápido.
Prueba Formmy
Si estás construyendo agentes, formularios inteligentes o cualquier cosa que hable con tus usuarios, Formmy te da la plataforma sin que tengas que pelearte con runtimes, containers y SQLite.
Prueba Formmy Gratis — Enfócate en tu producto, no en el plumbing.
¿Tienes preguntas sobre cómo escalamos agentes o sobre el stack técnico? Nuestro equipo responde.
