Next.js for Kubernetes: un binario, una caché y 2,4× más tráfico con la misma CPU
Compilé Next.js a un solo ejecutable de Bun y le di una caché compartida en Redis para todas las réplicas. En el camino aprendí a cazar fallas silenciosas, a desconfiar de mis benchmarks y a saber cuándo abandonar una idea que funciona.
software-architecture · nextjs · bun · kubernetes · redis

✓ Ready
Eso dijo el binario: un solo archivo con una aplicación completa de Next.js y su runtime adentro. Sin Node.js, sin node_modules. Abrí otra terminal e hice el primer curl:
HTTP/1.1 500 Internal Server Error
Entre el ✓ Ready y ese 500 pasaron unos diez segundos, y en esos diez segundos yo ya había abierto LinkedIn para anunciar que había compilado Next.js. Lo cerré despacio, como quien no quiere que nadie lo vea 😄.
Al final de esta historia vas a ver ese mismo binario corriendo en ocho réplicas dentro de Kubernetes, con una sola caché en Redis, atendiendo 2,4 veces más peticiones que Next.js sobre Node.js con la misma CPU. Pero vamos a llegar por el camino que recorrí yo: los mismos errores, en el mismo orden, con la misma cara de “¿y ahora qué hago?”.
Por qué
Next.js se despliega de maravilla en Vercel, y otros providers tienen adapters afinados para su plataforma. En Kubernetes, la receta es una imagen con Node.js, node_modules y next start. El tamaño de esas imágenes ya es una ofensa económica, pero lo peor es la caché: cada pod guarda la suya en su propio disco. Diez réplicas, diez versiones de la verdad. Invalidas una página en un pod y los otros nueve siguen sirviendo la vieja.
Al mismo tiempo, hay algo de los lenguajes compilados que admiro: el binario. En Go meto una SPA con embed y la API y el frontend viajan en un solo archivo. Con Leptos y Dioxus, en Rust, llegué a tener SSR en un solo binario. Pero un deploy elegante no justifica abandonar Next.js y el ecosistema de React. Lo que quedaba era llevarle esa capacidad al ecosistema.
El plan
Next.js 16 trae Build Adapters, un hook al final de next build que te entrega todo lo que produjo el build para que lo empaquetes como quieras. Bun compila JavaScript en un ejecutable único, con el runtime adentro. La hipótesis:
Un adapter que meta toda la app de Next.js en un solo binario de Bun, sobre una imagen
distroless(sin Node.js, sinnode_modules, sin shell), y que todas las réplicas compartan una sola caché.
Desde el principio hubo una tensión que vas a ver en cada capítulo: cuanto más agresivo me pusiera con el tamaño de binario, más me iba a acoplar a las tripas de Next y de Bun, dos proyectos que cambian rápido. Por eso, además de los problemas, te voy a mostrar cada decisión importante: qué elegí, qué descarté y qué costo acepté. Así, si algo se rompe en la próxima versión, podrías arreglarlo tú mismo.
Esto no es un tutorial; esto es una sesión de depuración larga, y tú irás sentado al lado. Vamos a cazar fallas que no dejan rastro en el log, un deadlock y un bug que durmió desde el primer día. Casi todos los problemas se entendieron con experimentos de diez o quince líneas, y te los dejo para que los corras tú. El código está en github.com/luiscib3r/next-compiler, con Next.js 16.4, React 19.3 y Bun 1.4.3.
Abre una terminal. Empecemos por ese 500.
Acto I: Un mundo sin file descriptors
Antes del 500, te cuento rápido cómo funciona el adapter, porque todo lo que viene depende de un detalle. Cuando termina next build, Next le pasa al adapter un objeto outputs con cada ruta de la app y los archivos que esa ruta necesita. El adapter hace tres cosas:
- Staging: arma una carpeta con todo lo que el servidor va a necesitar en producción, y nada más: los chunks de
.next/server, los estáticos,public/y los pedazos denode_modulesque realmente se usan. Es como la cama donde extiendes la ropa antes de hacer la maleta: lo que está en la cama entra; lo que no está, se queda en casa. A esa carpeta la voy a llamar el staging, y su contenido es exactamente lo que termina adentro del binario. - Generate: escribe un pequeño programa de arranque que llama a
startServerde Next y serializa la configuración a un JSON, para que el binario no tenga que cargarnext.config.tsen runtime. - Compile:
Bun.buildconcompileempaqueta ese programa en un ejecutable y embebe la carpeta del staging adentro.
flowchart TD
A["next build"] --> B["outputs: rutas y archivos"]
B --> C["Staging: solo lo que producción usa"]
B --> D["Arranque: startServer + config en JSON"]
C --> E["Bun.compile embebe el staging"]
D --> E
E --> F["Un ejecutable"]
F --> G["$bunfs/root, visible solo dentro del proceso"]
Ese último punto es el detalle. Los archivos embebidos viven en un sistema de archivos virtual que Bun expone dentro del proceso, en /$bunfs/root/. Desde el código puedes hacer readFileSync("/$bunfs/root/app/package.json") y funciona. Pero ese sistema de archivos solo existe dentro del proceso de Bun. El sistema operativo no tiene idea de que existe.
$bunfs es como otro planeta: se parece mucho a un sistema de archivos normal, pero tiene sus propias leyes físicas. La primera, llamémosla la ley cero, ya la había descubierto antes de que el binario llegara a decir ✓ Ready: ahí, Bun evalúa todos los .js como ES Modules, y los chunks que genera Next para el servidor son CommonJS (require, module.exports). La solución fue un require.ts que sobreescribe Module.prototype.require y, para las rutas de $bunfs, ejecuta cada archivo como CommonJS con vm.runInThisContext y el clásico wrapper (function (exports, require, module, __filename, __dirname) { ... }).
Con eso compilaba, arrancaba y devolvía 500. Vamos con las demás leyes.
Ley 1: aquí nadie busca en node_modules
El log del 500 decía esto:
⨯ Error: Failed to load external module next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:
ResolveMessage: Cannot find module 'next/dist/compiled/next-server/app-page-turbo.runtime.prod.js'
Require stack:
- /$bunfs/root/app/.next/server/chunks/ssr/[root-of-the-server]__0py1t7q9ntzgc._.js
Lo primero, lo obvio: ¿el archivo existe? Sí. Estaba en el staging, así que estaba embebido en el binario.
Pero fíjate cómo lo pide el chunk: por nombre de paquete (next/dist/...), no por ruta. Mi sospecha era que el resolver de Bun no hace la búsqueda en node_modules cuando el archivo que pide el módulo vive dentro de $bunfs. Podía ponerme a mirar el servidor de Next, que tiene mil piezas moviéndose, o armar el experimento más chico posible. Elegí el segundo, y te lo dejo para que lo corras.
Pruébalo tú
Un node_modules falso con un paquete foo, embebido en un binario que intenta resolverlo desde adentro. Antes de ejecutar, mueve la carpeta app: así te aseguras de que lo que funcione lo haga desde $bunfs y no desde el disco.
mkdir -p app/node_modules/foo app/sub
echo '{"name":"foo","main":"index.js"}' > app/node_modules/foo/package.json
echo 'module.exports = "soy foo";' > app/node_modules/foo/index.js
echo 'module.exports = "soy x";' > app/sub/x.js
// build.ts
await Bun.build({
entrypoints: ["./main.ts"],
compile: { outfile: "./exp", assets: ["./app"] },
});
// main.ts
import { createRequire } from "node:module";
const require = createRequire("/$bunfs/root/app/index.js");
for (const id of [
"./sub/x.js",
"/$bunfs/root/app/node_modules/foo/index.js",
"foo",
]) {
try {
console.log("✅", id, "→", require.resolve(id));
} catch (e) {
console.log("❌", id, "→", (e as Error).message);
}
}
bun build.ts && mv app app.bak && ./exp
✅ ./sub/x.js → /$bunfs/root/app/sub/x.js
✅ /$bunfs/root/app/node_modules/foo/index.js → /$bunfs/root/app/node_modules/foo/index.js
❌ foo → Cannot find module 'foo'
Rutas relativas y absolutas: bien. Nombres de paquete: mal. Node resuelve un require("foo") subiendo carpeta por carpeta en busca de node_modules/foo, y en $bunfs Bun simplemente no hace ese recorrido. Y si cambias require.resolve(id) por require(id), te vas a encontrar con la ley cero: module is not defined, porque ese .js se evaluó como ESM.
La solución: si Bun no lo hace, lo hago yo. Escribí un resolver propio que se le entrega a cada chunk como su require:
- Si es un módulo de Node (
fs,path…), se lo pasa alrequireoriginal. - Si es una ruta relativa o absoluta, la resuelve y prueba extensiones.
- Si es un nombre de paquete, sube por los directorios buscando
node_modules/<paquete>, lee supackage.jsony respetaexports(con las condicionesnode,requireydefault) ymain.
Hubo una trampa más. El siguiente error fue Cannot find module 'next/dist/compiled/source-map', y ese archivo también existía. Resulta que no es un archivo: es una carpeta con su propio package.json que dice "main": "source-map.js". Node lee ese main cuando un require apunta a una carpeta, y mi resolver solo buscaba index.js. Una línea más, y por fin:
HTTP/1.1 200 OK
x-nextjs-cache: HIT
Total, ¿qué tan difícil podía ser reimplementar el algoritmo de resolución de módulos de Node? Más de lo que uno admitiría en una daily.
Decisión
$bunfs es la única fuente de los archivos de la app, y los módulos se resuelven en runtime con un resolver propio.
Alternativa descartada: extraer la app a disco al arrancar y dejar que Bun resuelva como siempre. Es más simple, pero exige disco escribible en cada pod y duplica todo lo que ya está en el binario.
Costo que acepto: mantener un resolver compatible con el de Node. Si un paquete usa una forma de resolución que no cubro, va a fallar.
Ley 2: aquí no existen los file descriptors
La página cargaba, pero el CSS, las imágenes de public/ y el favicon devolvían 500. Y esta vez el log estaba vacío. Ni un error. El servidor fallaba con la serenidad de un monje tibetano.
Next sirve los estáticos con el paquete send, que hace un fs.stat y después un fs.createReadStream. Volví a la técnica del binario mínimo, ahora probando distintas APIs de fs contra un archivo embebido.
Pruébalo tú
Usa el mismo build.ts de antes, con un app/style.css cualquiera.
// main.ts
import fs from "node:fs";
const file = "/$bunfs/root/app/style.css";
const tests: Record<string, () => unknown> = {
readFileSync: () => fs.readFileSync(file, "utf8"),
statSync: () => fs.statSync(file),
openSync: () => fs.openSync(file, "r"),
realpathSync: () => fs.realpathSync(file),
createReadStream: () =>
new Promise((ok, fail) =>
fs.createReadStream(file).on("data", ok).on("error", fail),
),
};
for (const [name, test] of Object.entries(tests)) {
try {
await test();
console.log("✅", name);
} catch (e) {
console.log("❌", name, "→", (e as NodeJS.ErrnoException).code);
}
}
✅ readFileSync
✅ statSync
❌ openSync → ENOENT
❌ realpathSync → ENOENT
❌ createReadStream → ENOENT
El patrón es clarísimo: en $bunfs no existen los file descriptors. Todo lo que necesita abrir un fd falla; leer el archivo completo o hacerle stat funciona. send necesita createReadStream, así que revienta, y como Next atrapa el error y responde un 500 genérico, no queda rastro en el log.
fs es un módulo compartido: si lo modificas, lo ve todo el proceso, incluido el código de Next que Bun empaquetó en el binario. Así que sobreescribí createReadStream solo para las rutas de $bunfs, armando el stream desde readFileSync y respetando start y end, para que las peticiones con Range (las de los videos, por ejemplo) sigan funcionando:
fs.createReadStream = function (path, options) {
if (typeof path !== "string" || !path.includes("$bunfs")) {
return original.call(this, path, options);
}
const { start = 0, end = Infinity } = options ?? {};
const data = fs.readFileSync(path);
return Readable.from([data.subarray(start, end + 1)]);
};
Podría haber escrito mi propio servidor de estáticos, pero los headers de caché, los ETag y los Range los resuelve Next, y tendría que reimplementarlos. El costo de este parche es que cada estático se lee completo a memoria antes de responder. Para CSS e íconos da igual; para un video de 200 MB en public/, no. Pero ese tipo de archivos en producción deberían salir de un CDN de todos modos, así que acepté el costo.
Ley 3: la API no te lo cuenta todo
Con todo eso resuelto, /favicon.ico seguía fallando:
Failed to load external module next/dist/compiled/next-server/app-route-turbo.runtime.prod.js
Esta vez el archivo no estaba en el staging. Para entender por qué, hice que el adapter volcara a un JSON el objeto outputs que le entrega Next. Ahí estaba la respuesta: el favicon de la App Router es técnicamente un route handler, pero como Next lo prerenderiza en el build, lo reporta como STATIC_FILE. El adapter recorría las rutas y sus dependencias, así que nunca miró las de ese handler. Pero en runtime, el servidor sí carga el módulo.
Técnicamente correcto, que es el mejor tipo de correcto y también el más inútil cuando estás depurando.
La solución fue no depender solo de la API. Junto a cada archivo del servidor, Next deja un .nft.json (de Node File Trace) con la lista de archivos que ese módulo necesita. Ahora el staging también recorre todos esos .nft.json y copia lo que falte. El costo: esas trazas son un detalle interno de Next, no un contrato público. Si cambian de formato en un upgrade, el adapter puede dejar archivos afuera. Ya vas a ver qué me protege de eso.
La sospecha que no fue (todavía)
Había algo que me preocupaba desde el principio y que, curiosamente, no fallaba.
Los chunks de Turbopack piden módulos con nombres como work-async-storage.external.js. Son instancias de AsyncLocalStorage, el mecanismo que usa Next para que headers(), cookies() o after() sepan a qué request pertenecen. El servidor y los chunks tienen que compartir la misma instancia: si cada uno tuviera la suya, headers() dejaría de funcionar.
Y en el binario hay dos copias de ese módulo: la que Bun empaquetó dentro del servidor y la que mi resolver carga desde $bunfs. Armé páginas dinámicas que leen headers y cookies y un route handler con after(), y las comparé contra next start. Funcionaban igual. ¿Por qué? Por esto, en el código de Next:
const workAsyncStorageInstance =
getOrCreateGlobalAsyncLocalStorage("work-async-storage");
Next guarda esas instancias en globalThis, justamente para sobrevivir a que el módulo se cargue más de una vez. Dos copias del módulo, una sola instancia. Taché la sospecha de la lista y seguí.
Acuérdate de esto de las dos copias. Va a salir otra vez, y no va a ser tan fácil de resolver.
Una red de seguridad
A esta altura, probar con curl a mano ya no daba para más. Pero había una razón más importante para automatizar: cada vez que actualice Next o Bun, el adapter se puede romper por dentro de mil formas que no controlo. No puedo evitar ese riesgo, pero sí puedo detectarlo en minutos. En Building Evolutionary Architectures, Neal Ford, Rebecca Parsons y Patrick Kua llaman a esto una fitness function: una prueba que protege una característica de la arquitectura, no una funcionalidad. Aquí, la característica es que el binario se comporte exactamente como next start.
Así que armé un sitio de pruebas dentro del proyecto, donde cada página ejercita algo que el binario tiene que soportar: renderizado estático y dinámico, streaming, "use cache" con tags, ISR, Server Actions, redirects y rewrites, proxy.ts, next/image, imágenes Open Graph y route handlers. Un smoke test recorre cada ruta y valida status, contenido y headers:
bun run smoke # contra el binario
bun run smoke http://localhost:3000 # contra next start
La regla más importante: antes de correrlo contra el binario, lo corrí contra next start. Si una prueba falla con el servidor oficial, el problema está en la prueba, no en el adapter. Primero se valida la regla y después se mide.
Contra next start pasó todo. Contra el binario, todo menos dos pruebas.
Ley 4: aquí process.cwd() no apunta a tu app
La primera de esas dos fallas era una página que leía un JSON con fs.readFile(join(process.cwd(), "data/runtime.json")), un patrón comunísimo. En el binario, el archivo está embebido en /$bunfs/root/app/data/, pero process.cwd() es el directorio real desde donde se lanzó el proceso.
Podría haber parcheado process.cwd(), pero habría sido un parche global más, cambiando el significado de una API que mucho código da por sentada. En cambio, fijé una regla: en una app que vive en un binario, el código no lee archivos del disco a propósito. Si necesitas datos estáticos, los importas (import data from "./data.json") y quedan dentro del bundle. Es la primera convención que la app tiene que seguir para correr en el binario, y por eso queda escrita. No todo problema técnico necesita una solución técnica. A veces necesita una regla, y una regla explícita es mejor que un parche invisible que alguien va a descubrir en producción.
La otra falla eran las imágenes Open Graph, y la iba a dejar para después. Pero antes de seguir pasó algo peor que una prueba roja: me di cuenta de que una de las pruebas en verde me estaba mintiendo.
Le pedí a /_next/image una imagen de 32 píxeles en webp. next start devolvía un webp de 32×32. El binario devolvía el PNG original de 64×64, con status 200 y sin una sola línea en el log.
Acto II: El descenso
Next usa sharp para optimizar imágenes y, si sharp no carga, sirve la imagen original sin avisar. Es como pedir un café descafeinado y que te sirvan uno normal porque se acabó el descafeinado, pero sin decírtelo. Técnicamente recibiste café.
Mi smoke test no lo había detectado porque solo validaba que la respuesta fuera image/*. Lo endurecí para exigir webp, y desde ahí me quedó una idea que vuelve a aparecer en toda esta historia: las fallas silenciosas son las peores. El sistema “funciona”, pero no hace lo que crees.
Lo que no cabe en la maleta
sharp no es JavaScript puro. Tiene dos partes:
@img/sharp-linux-arm64/lib/sharp-linux-arm64-0.35.5.node ← el puente JS ↔ C (0,5 MB)
@img/sharp-libvips-linux-arm64/lib/libvips-cpp.so.8.18.7 ← la librería que hace el trabajo (18 MB)
El .node se carga con dlopen, una llamada del sistema operativo, y eso trae dos requisitos:
- Tiene que ser un archivo real en disco. El sistema operativo lo mapea en memoria como código ejecutable, y ya sabes que
$bunfssolo existe dentro del proceso de Bun. - Busca sus librerías en rutas relativas a sí mismo. Con
readelfse ve que el.nodetrae grabado unRPATH:
RPATH: $ORIGIN/../../sharp-libvips-linux-arm64/lib
$ORIGIN es la carpeta donde está el .node. Si libvips no está dos niveles más arriba, en esa ruta exacta, el dlopen falla. En macOS es lo mismo con otros nombres: @rpath y @loader_path.
Con un binario mínimo descubrí algo curioso: Bun sí embebe el .node, e incluso detecta y embebe libvips. Al cargarlo, extrae el .node a un temporal (/tmp/.bun-xxxx.node) y le hace dlopen. Pero no extrae libvips, así que el .node queda solo en /tmp, buscando su librería en una ruta que no existe:
dlopen(/tmp/.bun-501-9bc4....node): Library not loaded: @rpath/libvips-cpp.8.18.7.dylib
tried: '/tmp/../../sharp-libvips-darwin-arm64/lib/libvips-cpp.8.18.7.dylib' (no such file)
Bun armó bien el paquete y a la hora de entregarlo, sacó solo el control remoto y dejó el televisor en el camión de entrega.
El desvío
La primera idea fue esquivar el problema. sharp tiene una variante en WebAssembly (@img/sharp-wasm32): sin dlopen ni librerías nativas, solo hay que leer un .wasm, y readFileSync funciona en $bunfs. Parecía perfecto.
Cuando lo probé el binario se colgó. No falló: se colgó. Y ahí empezó la investigación más entretenida de todo el proyecto.
Primera pista: también se colgaba en Bun sin compilar, mientras que en Node funcionaba. El problema no era el binario ni $bunfs: era Bun con este WASM.
Un falso positivo: en un momento creí que ya funcionaba. Para forzar el uso de WASM, había hecho fallar a propósito la carga del módulo nativo interceptando Module._load… pero en Bun el require no pasa por Module._load. El hook nunca se activó y yo estaba usando el nativo sin saberlo. Lo descubrí porque un log que puse dentro del hook nunca se imprimió. Repetí todo en un node_modules aislado que solo tenía la variante WASM. Desde ahí, antes de creerle a un “funciona”, compruebo que el camino que creo que se ejecutó sea el que de verdad se ejecutó.
Acotando: el módulo cargaba en 73 ms, pero la primera operación asíncrona (metadata()) bloqueaba el hilo principal para siempre, con 0 % de CPU. Ni siquiera un setTimeout de 3 segundos llegaba a ejecutarse. Eso huele a deadlock: dos hilos esperándose el uno al otro con la paciencia de una reunión que pudo haber sido un correo.
El código que genera Emscripten (el glue que acompaña al WASM) usa workers como hilos, así que fui descartando hipótesis con experimentos de diez líneas:
| Hipótesis | Experimento | Resultado |
|---|---|---|
| Los workers no arrancan si el hilo principal está bloqueado | Worker + Atomics.wait | ❌ En Bun funciona |
Bun no puede mandarle un WebAssembly.Module y una Memory compartida a un worker | postMessage({ memory, module }) | ❌ Funciona |
postMessage no se entrega si el que lo manda está bloqueado | postMessage + Atomics.wait | ❌ Funciona |
| El worker arranca, pero nunca recibe el mensaje con el WASM | Trazas dentro del glue de Emscripten | ✅ Confirmada |
Las trazas las escribí con fs.writeSync(2, ...) y no con console.log, porque los console.log de un worker pasan por el hilo principal… que estaba bloqueado. En Node, el worker recibía el mensaje que le pasa el WASM. En Bun, nunca.
La causa estaba en estas líneas del glue, que se ejecutan dentro del worker:
if (!globalThis.postMessage) {
// ¿no estoy en un Web Worker?
parentPort.on("message", (msg) => globalThis.onmessage?.({ data: msg }));
globalThis.postMessage = (msg) => parentPort.postMessage(msg);
}
El glue pregunta: “¿existe postMessage global?”. Si no existe, asume que está en un worker de Node y se suscribe a parentPort. Si existe, asume que está en un Web Worker del navegador y espera los mensajes en onmessage.
Pruébalo tú
Guárdalo como worker.mjs y córrelo con node worker.mjs y con bun worker.mjs.
import { Worker, isMainThread, parentPort } from "node:worker_threads";
if (isMainThread) {
const worker = new Worker(new URL(import.meta.url));
worker.postMessage("hola");
setTimeout(() => process.exit(0), 500);
} else {
console.log("typeof globalThis.postMessage:", typeof globalThis.postMessage);
parentPort.on("message", (m) => console.log("parentPort recibió:", m));
globalThis.onmessage = (e) => console.log("onmessage recibió:", e.data);
}
--- node
typeof globalThis.postMessage: undefined
parentPort recibió: hola
--- bun
typeof globalThis.postMessage: function
parentPort recibió: hola
Bun expone un postMessage global también en los workers de node:worker_threads, pero no les entrega los mensajes a onmessage. El glue cree que está en un navegador, se salta la suscripción a parentPort y espera un mensaje que nunca llega. Mientras, el hilo principal espera al worker. Deadlock.
Cambié esa condición por if (true) en una copia del glue, y sharp en WASM generó el webp en 44 ms. Causa confirmada.
Saber cuándo abandonar
Tenía la causa y tenía una solución que funcionaba. Y aun así, la descarté.
Decisión
No seguir por WebAssembly.
Por qué: para integrarlo había que parchear un paquete de terceros, armar un arranque especial para sus workers (Emscripten crea el worker apuntando a su propio archivo, que dentro del bundle sería el binario completo) y hacer que ese worker funcionara sin mis hooks de require. Eran tres piezas frágiles más para mantener en cada upgrade, para resolver algo que en producción va a hacer un CDN.
Lo que gané igual: la causa, documentada y reproducible en doce líneas. Es una incompatibilidad real entre Bun y Node, y si mañana alguien la necesita, el análisis ya está hecho.
Saber cuándo abandonar un camino que funciona también es sabio. Y una investigación no es tiempo perdido porque termine en un “no”.
La solución: sacar de la maleta solo lo que no cabe
Decidí resolver sharp nativo, pero directamente en el destino real: Linux, en una imagen distroless. Armé un Dockerfile de dos etapas: el build en la imagen oficial de Bun (oven/bun) y el runtime en gcr.io/distroless/cc-debian13, que trae lo mínimo que Bun y sharp necesitan para correr. Antes de llegar a sharp apareció una piedra.
El servidor escuchaba en 127.0.0.1. Dentro de un contenedor, el tráfico llega por la interfaz de red del pod, no por loopback. Ahora escucha en 0.0.0.0, con una variable HOST para cambiarlo. Y ojo: no uso HOSTNAME, como hace el modo standalone de Next, porque Kubernetes define HOSTNAME con el nombre del pod en todos los contenedores, y el servidor terminaría escuchando solo en la IP del pod. Romper la paridad con Next en ese punto fue una decisión consciente.
Antes de diseñar algo propio, miré cómo lo habían resuelto otros. next-bun-compile hace algo parecido y ya se había topado con este problema. Su enfoque es extraer todo a disco al arrancar: chunks, node_modules y estáticos. Después, Next corre como un next start normal. Es una decisión válida, que privilegia la simplicidad a cambio de necesitar siempre disco escribible y duplicar la app. Yo quería seguir sirviendo todo desde $bunfs, así que me quedé con su lección central (el addon necesita disco real) y la apliqué de forma acotada: extraer a disco solo los paquetes nativos, y solo la primera vez que alguien los pide.
flowchart TD
subgraph Build["Build (adapter)"]
B1["Recolectar sharp + dependencias"] --> B2["Filtrar por plataforma<br/>(os, cpu, libc)"]
B2 --> B3["Copiar al staging<br/>+ escribir .native.json"]
B3 --> B4["Marcar sharp como external"]
end
subgraph Runtime["Runtime (el binario)"]
R1["Next: require('sharp')"] --> R2{"¿Es un paquete<br/>nativo?"}
R2 -- no --> R3["Resolver de $bunfs"]
R2 -- sí --> R4["Primera vez: copiar<br/>$bunfs → /tmp/next-native-id"]
R4 --> R5["require desde disco"]
R5 --> R6["dlopen encuentra libvips<br/>gracias a $ORIGIN"]
end
Build --> Runtime
En el build, el adapter parte de sharp y junta sus dependencias. Las optionalDependencies son los binarios precompilados, uno por plataforma, y cada uno declara dónde funciona ("os": ["linux"], "cpu": ["arm64"], "libc": ["glibc"]). El adapter compara esos campos con la máquina del build y descarta lo que no sirve: en Linux, Bun instala también las variantes musl, que en glibc no sirven para nada. Copia al staging lo que queda, escribe un manifiesto (.native.json) con un id que cambia en cada build y marca sharp como external para que Bun no lo empaquete.
En runtime, el hook de require reconoce sharp. La primera vez, copia los paquetes del manifiesto desde $bunfs a /tmp/next-native-<id>/node_modules/ (o a donde diga NATIVE_MODULES_DIR). Con la estructura de node_modules en disco, el resolver normal de Bun funciona y el RPATH del .node encuentra libvips donde lo espera. Y si la app nunca optimiza una imagen, no se escribe nada en disco.
Dos detalles me costaron más de lo que parece. El primero es que la copia no puede hacerse con cpSync, porque cpSync abre los archivos y, como aprendimos en la segunda ley, en $bunfs no hay file descriptors. Así que copio archivo por archivo, con readFileSync + writeFileSync.
El segundo es más traicionero. Mi hook funciona así: cada vez que alguien hace require("sharp"), lo intercepta, extrae los paquetes a disco y carga sharp desde ahí. El problema está en ese “carga sharp desde ahí”. Si para cargarlo vuelvo a escribir require("sharp"), mi propio hook lo intercepta otra vez, que vuelve a llamar a require("sharp"), que vuelve a interceptar… y así hasta que revienta el stack. Nada te hace sentir tan humilde como un hook que se llama a sí mismo.
La salida es no volver a pedirlo por nombre. Después de extraer, resuelvo sharp a la ruta absoluta del archivo en disco y hago require de esa ruta:
const dir = extract(); // /tmp/next-native-<id>/node_modules/...
const path = createRequire(join(dir, "/")).resolve("sharp");
// → /tmp/next-native-<id>/node_modules/sharp/lib/index.js
return createRequire(path)(path);
El hook solo intercepta los nombres de paquetes nativos (sharp o sharp/...). Una ruta como /tmp/next-native-<id>/node_modules/sharp/lib/index.js no coincide, así que pasa de largo y la carga el require normal de Bun, que ahora sí encuentra todo en disco.
Por último, como Next se traga los errores de sharp, ahora el runtime registra la causa cuando un paquete nativo no carga. La falla silenciosa de la primera escena ya no puede volver a pasar en silencio.
Decisión
Extraer a disco solo los paquetes nativos, de forma perezosa, a un directorio configurable.
Alternativas descartadas: extraer toda la app al arrancar (siempre requiere disco escribible y duplica la app); copiar las librerías nativas junto al binario en el Dockerfile (deja de ser un solo artefacto); sharp en WebAssembly (ya viste por qué).
Costo que acepto: si la app usa sharp, el pod necesita un directorio escribible que permita ejecutar código. Al menos es descartable: si el pod muere, el siguiente lo reconstruye solo. Y por ahora, la lista de paquetes nativos es manual: ["sharp"].
El sistema de archivos también opina
En Kubernetes es común correr con readOnlyRootFilesystem, así que probé varios escenarios en Docker:
| Escenario | Resultado |
|---|---|
| Normal | ✅ webp |
--read-only | ❌ EROFS: read-only file system |
--read-only --tmpfs /tmp | ❌ failed to map segment from shared object |
--read-only --tmpfs /tmp:exec | ✅ |
--read-only + volumen en NATIVE_MODULES_DIR | ✅ |
El tercero es el interesante. Docker monta --tmpfs como noexec por defecto, y Linux no deja mapear código ejecutable desde un filesystem noexec. En Kubernetes, un emptyDir normal no es noexec, pero algunas políticas de seguridad lo fuerzan. Si te pasa, ahora el pod te lo dice con todas las letras.
Los addons nativos no son “un archivo más”. Tienen requisitos del sistema operativo (un archivo real, permiso de ejecución, rutas relativas) que ninguna capa de JavaScript puede esquivar.
La última prueba roja
Quedaba una falla pendiente del Acto I, las imágenes Open Graph:
Failed to load external module next/dist/compiled/@vercel/og/index.node.js:
TypeError [ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING]
next/og está publicado como ESM, así que el runtime de Turbopack lo carga con import() en vez de require(). Y ese runtime es uno de los archivos que mi require.ts ejecuta con vm.runInThisContext. El código que corre dentro de vm no tiene import() propio: hay que pasarle una función que lo resuelva, en la opción importModuleDynamically. En Node eso exige un flag experimental; en Bun, una función propia funciona tal cual. Así que resuelvo el nombre con el mismo resolver de $bunfs y hago import() de la ruta absoluta.
Lo bonito es que aquí la ley cero del mundo $bunfs, la que nos obligó a escribir todo el parche de CommonJS, juega a favor: Bun evalúa los .js de $bunfs como ESM, y este archivo es ESM. Lo que antes era un obstáculo, aquí es justo lo que necesitábamos.
Y de paso se resolvió un misterio: las trazas de la ruta de Open Graph incluían sharp. Resulta que @vercel/og hace import("sharp") como rasterizador opcional: si existe lo usa, y si no, usa resvg en WebAssembly.
Con eso, el smoke test pasó completo, en la imagen distroless de Linux.
Un solo archivo, sin Node.js, sin node_modules, sin shell, haciendo todo lo que hace next start. Me sentí invencible durante unos minutos. Después medí el binario: 170 MB. La imagen de Docker, 208. Había empezado todo esto para correr Next.js de forma más eficiente que con Node, y todavía no tenía ni idea de si lo había logrado.
Acto III: La dieta
Antes de cortar nada, medí de qué estaba hecho el binario. Si te saltas este paso, terminas optimizando lo que crees que pesa, y yo tenía mi apuesta: Next duplicado, una copia empaquetada en el servidor y otra en $bunfs. Spoiler: esa duplicación pesaba menos de 5 MB.
| Parte | Tamaño |
|---|---|
| Runtime de Bun | 88 MB |
$bunfs (la app embebida) | 61 MB |
| Bundle JS del servidor | ~21 MB |
| Total | 170 MB |
El runtime de Bun es el piso: no se puede reducir. Quedaban las otras dos partes.
La cocina completa para ir de picnic
Le pedí a Bun un metafile del bundle (el registro de qué entró y por qué) y lo agrupé por paquete. El servidor de producción traía webpack (2,4 MB), babel (2,7 MB), las devtools de Next, postcss, cssnano, caniuse-lite, un JSON de métricas de fuentes de 2,3 MB y hasta el servidor MCP que usa next dev.
En lugar de ir sacando paquetes uno por uno, calculé la cadena de imports más corta desde el punto de entrada hasta cada uno. Casi todo entraba por dos puertas:
server.ts
└─ next/dist/server/lib/start-server.js
└─ next/dist/server/lib/router-server.js
└─ router-utils/setup-dev-bundler.js ← puerta 1
└─ dev/hot-reloader-webpack.js
└─ compiled/webpack/webpack.js
server.ts
└─ next/dist/server/next.js
└─ dev/next-dev-server.js ← puerta 2
└─ next-devtools/...
Y en el código de Next, las dos están detrás de una condición:
if (opts.dev) {
const { setupDevBundler } = require('./router-utils/setup-dev-bundler');
El binario arranca con dev: false, así que esas ramas nunca se ejecutan. Pero un bundler no sabe qué ramas se van a ejecutar: sigue cualquier require que pueda ver. Me estaba llevando la cocina completa al picnic, por si a alguien se le ocurría compilar algo en producción.
Intenté excluirlas con --external, pero --external compara contra el texto del require, y aquí son rutas relativas. No funcionó en ninguna de sus formas. Lo que sí funcionó fue un plugin de Bun que, cuando el bundler carga esos dos archivos, los reemplaza por un stub:
build.onLoad({ filter: /next[\\/]dist[\\/].*\.js$/ }, (args) => {
const devOnly = DEV_ONLY_MODULES.find((name) => args.path.endsWith(name));
if (!devOnly) return undefined;
return {
loader: "js",
contents: `throw new Error("${devOnly} is dev-only and is not in the binary");`,
};
});
El stub no tiene requires, así que Bun no sigue el camino hacia webpack ni babel. El bundle del servidor bajó de 18,6 MB a 4,8 MB.
¿Te acuerdas de la tensión del prólogo, la de que cuanto más achico el binario, más me acoplo a las tripas de Next? Esta es la decisión donde más se nota. router-utils/setup-dev-bundler.js no es una API pública: es una ruta interna que Next puede mover mañana. Así que la pregunta no era si se podía romper, sino cómo se iba a romper.
Decisión
Reemplazar los dos módulos de desarrollo de Next por stubs en el build.
Costo que acepto: acoplamiento a rutas internas de Next, que un upgrade puede mover o renombrar.
Cómo falla: de forma segura, en los dos sentidos.
- Si Next mueve el archivo, el stub simplemente no aplica: el binario vuelve a pesar 14 MB más, pero funciona igual.
- Si algún día el servidor de producción sí necesitara uno de esos módulos, el stub lanza un error que dice exactamente qué pasó, y el smoke test lo atrapa antes de que llegue a un pod.
Esa propiedad es lo que hace aceptable el acoplamiento. No me preocupa que una optimización deje de aplicar; me preocuparía que rompiera algo en silencio.
Lo que sobraba en la maleta
En $bunfs también había equipaje de más:
- Las variantes
musldesharp(19 MB). Entraban por las trazas de la ruta de Open Graph, por eseimport("sharp")opcional de@vercel/og, y no pasaban por mi filtro de plataforma. Ahora el staging borra los paquetes que el filtro descarta, sin importar por dónde entraron. - Los source maps (12 MB). En runtime no se usan. El adapter es para producción en Kubernetes y nada más, así que todo lo que no se necesita para correr ahí, sale.
- Los
.nft.json(0,5 MB). Solo sirven durante el build.
Y una más: si la app declara images.unoptimized: true en next.config (porque las imágenes las optimiza un CDN), Next nunca usa sharp, así que el adapter lo deja fuera del binario junto con sus librerías: unos 20 MB menos. Me gusta dónde queda el control: no es una opción nueva del adapter que alguien tenga que descubrir, sino la configuración que la app ya declara. Si la app dice que no optimiza imágenes, el binario no carga con la herramienta para hacerlo.
| Antes | Después | Con images.unoptimized | |
|---|---|---|---|
| Binario | 170 MB | 124 MB | 104 MB |
| Imagen Docker | 208 MB | 163 MB | 143 MB |
Imagen comprimida (lo que baja un pull) | 80 MB | 65 MB | — |
La imagen comprimida baja menos en proporción, porque lo que quitamos (JavaScript, source maps) se comprime muy bien. De esos 65 MB, unos 42 son el runtime de Bun: ya estamos cerca del piso.
Contra Node, pero en serio
Un número suelto no dice nada. La pregunta que se hace un equipo de verdad no es “¿cuánto pesa?”, sino “¿esto, o Next.js sobre Node.js como lo despliega todo el mundo?”. Así que armé un segundo Dockerfile con la forma clásica. Y aquí hay una trampa en la que es facilísimo caer sin querer.
Decisión
Comparar contra la mejor configuración razonable de Node.js, no contra un hombre de paja.
Es muy fácil ganarle a una imagen node:24 de 1 GB con npm install adentro, y también es inútil: nadie serio despliega eso en producción. Así que la imagen de Node usa output: "standalone" (el modo que Next recomienda para Docker, que copia solo lo que el servidor necesita), se construye con Node y corre sobre distroless/nodejs24-debian13, de la misma familia que la del binario. Como referencia, también medí node:24-alpine, la del ejemplo oficial de Next, porque es la que la mayoría copia y pega.
Requisito: las tres imágenes tienen que pasar el mismo smoke test. Una imagen chica que no funciona no compite: decora.
Una variable de entorno en next.config.ts (BUILD_TARGET=node) activa el standalone y apaga el adapter, así que las dos variantes salen exactamente del mismo código.
| Imagen | Sin comprimir | Comprimida |
|---|---|---|
El binario sobre distroless/cc-debian13 | 162,6 MB | 65,3 MB |
Node 24 standalone sobre distroless/nodejs24-debian13 | 224,0 MB | 77,4 MB |
Node 24 standalone sobre node:24-alpine | 229,1 MB | 82,2 MB |
El binario es un 27 % más chico sin comprimir y un 16 % más chico comprimido que la mejor variante de Node. Pero un número sin desglose es una anécdota, así que separé cada imagen por capas:
| Capa | Binario | Node (distroless) |
|---|---|---|
| Base (glibc, libstdc++, OpenSSL…) | ~38 MB | ~38 MB |
| Runtime | Bun: 88 MB | Node: 123 MB |
App (Next, sharp, código) | 36 MB, dentro del binario | 63 MB (el standalone) |
- El runtime es la diferencia más grande. Node 24 pesa unos 35 MB más que Bun, y ahí no hay nada que optimizar: es el precio de entrada de cada uno.
- El standalone de Node trae
sharpdos veces, las variantes glibc y musl. Es exactamente el problema que arreglamos en el adapter: Next traza todo lo que está instalado, aunque esa plataforma nunca lo vaya a cargar. Fue reconfortante descubrir que no era un bug de mi adapter, sino un rasgo de familia. - Next completo contra solo lo necesario. En el standalone,
node_modules/nexttrae todo lo que el servidor podría llegar a cargar, incluida la cocina del picnic.
¿Y alpine? Pesa más que distroless, y a más de uno le va a doler. Alpine es un sistema operativo chico, pero la imagen node:24-alpine trae Node con npm, yarn y corepack. Alpine es liviano pero la imagen de Node no se enteró.
Para ser justos
Si a la imagen de Node también se le quita la variante musl de sharp, baja a unos 205 MB y la diferencia se reduce a unos 42 MB. No lo apliqué en la comparación porque eso ya no es la forma clásica de desplegar Next, que es justo lo que quería medir. Pero el número tiene que estar sobre la mesa.
Ganar en tamaño se sintió bien durante unos tres minutos. Después caí en la cuenta de que el tamaño de la imagen se paga una vez por nodo, cuando el pod se programa ahí. La CPU y la RAM, en cambio, se pagan cada segundo que el pod está vivo, multiplicadas por cada réplica. Lo que hice hasta acá era la mitad fácil. La otra mitad era ponerle carga, y la carga, ya lo vas a ver, tiene una facilidad impresionante para mentir.
Acto IV: Los números también mienten
Armé pruebas de carga con k6 contra las dos imágenes. Y antes de mostrarte un solo número, te aviso: esta primera ronda la corrí en mi Mac, con OrbStack. Sí, ya sé. Te la cuento igual, porque los números ya dicen cosas interesantes y porque lo que no dicen terminó siendo lo más útil de todo el acto.
En un benchmark es facilísimo hacer trampa sin querer, así que fijé las reglas antes de correr nada:
- Mismos recursos: cada contenedor con 1 CPU y 1 GiB de memoria, como un pod con
limits. Uno a la vez, nunca los dos en paralelo, para que no compitan por la máquina. - k6 dentro de la misma red de Docker, en su propio contenedor. Si lo corro desde el host, estaría midiendo también la red de Docker en macOS.
- Un contenedor nuevo por escenario, para que ninguno herede la caché ni la memoria del anterior, y las imágenes intercaladas: si la máquina se calienta, les afecta a las dos por igual.
- 10 segundos de calentamiento descartados y 30 medidos, con 32 usuarios virtuales que mandan peticiones sin pausa. Eso mide la capacidad máxima y la latencia cuando el servidor está saturado.
Next no hace una sola cosa, así que tampoco hay un solo número de rendimiento. Cada escenario estresa algo distinto:
| Escenario | Qué estresa | Binario req/s | Node req/s | Binario vs. Node | p95 binario / Node |
|---|---|---|---|---|---|
static | HTML prerenderizado | 3.509 | 2.495 | 1,4× | 11,7 / 20,3 ms |
dynamic | SSR con headers(), cookies() y searchParams | 586 | 281 | 2,1× | 95 / 158 ms |
cache | Partial prerendering con "use cache" | 435 | 216 | 2,0× | 231 / 292 ms |
api | Route handler dinámico con after() | 3.964 | 2.312 | 1,7× | 10 / 40 ms |
asset | Un archivo de .next/static | 20.147 | 7.489 | 2,7× | 3,2 / 6,7 ms |
isr-new | ISR con una página nueva en cada request | 111 | 58 | 1,9× | 445 / 926 ms |
image | Optimizar imágenes con sharp | 6.226 | 9.949 | 0,6× | 9,0 / 4,9 ms |
Cero errores en todos los escenarios. Ahora, la parte divertida: leerlos con lupa.
Donde hay que renderizar, el binario procesa el doble. En SSR, "use cache" e ISR, la diferencia ronda las 2×, y con menos latencia. Es el trabajo que más pesa en una app real; servir HTML ya hecho lo hace cualquiera.
Los estáticos vuelan, pero en parte es mérito prestado. 20 mil peticiones por segundo contra 7,5 mil. Una parte de esa ventaja viene del parche de la segunda ley: el createReadStream del binario lee el archivo de memoria, mientras que Node lo lee de disco con un stream. No es solo Bun siendo rápido. Y en producción estos archivos los va a servir un CDN, así que este número es más curiosidad que argumento.
Imágenes: el único escenario donde gana Node. Le pedí la misma imagen tres veces a cada uno:
Binario: MISS → MISS → MISS la optimiza con sharp en cada petición
Node: MISS → HIT → HIT la sirve desde .next/cache/images
El binario no guardaba las imágenes optimizadas en ningún lado. Next intentaba escribirlas en .next/cache, que en el binario vive dentro de $bunfs (de solo lectura), fallaba y seguía como si nada. Sí, otra falla silenciosa. A esta altura ya debería haberlas buscado primero. Y ojo: si aun así llegó a 6 mil req/s, es porque la imagen de prueba es de 64×64 y sharp la procesa en unos 5 ms. Con una foto real, cada petición pagaría la optimización completa y ese número se desplomaría.
La memoria, el número que más engaña. En los escenarios pesados, el binario usó más RAM que Node: 281 contra 241 MiB en SSR, y 537 contra 356 MiB en ISR. Leído así, parece una mala noticia. Pero en esta prueba los usuarios virtuales mandan peticiones sin pausa, así que el binario atendía el doble de peticiones en el mismo tiempo, con el doble de trabajo en vuelo. Comparar la memoria así es como decir que un bus consume más que un auto sin contar a los pasajeros.
Lo que sí me dejó una duda seria fue ISR. Sin disco, cada página nueva que se renderiza se queda en la memoria del pod. ¿Eso se estabiliza en algún punto, o crece sin techo?
La letra chica
Esta corrida es una primera foto, no una conclusión. Y sus limitaciones, sin maquillaje, son estas:
- macOS con OrbStack. Los contenedores corren en una VM Linux, compartiendo CPU con todo lo demás que pasa en mi máquina. No es un nodo de Kubernetes.
- Una sola corrida. No sé cuánto varían estos números de una ejecución a otra. Sin repeticiones, una diferencia del 20 % podría ser ruido.
- Saturación, no carga real. Mide la capacidad máxima, no cómo se comporta cada uno a la misma carga. Por eso la comparación de memoria es injusta.
- Un solo contenedor con 1 CPU. Sin réplicas, sin balanceador, sin red real entre pods.
Un benchmark en un laptop es como una prueba de manejo en el estacionamiento del concesionario: te dice si el auto arranca, no cómo se porta en la carretera.
Esta corrida no me dijo qué tan rápido es el binario. Me dijo qué tenía que medir después, y con qué cuidado. Pero antes de cualquier otra prueba había algo más urgente. Fíjate en los dos peores resultados: las imágenes que se optimizaban en cada petición y los 537 MiB de ISR. Tienen la misma causa: la caché vive dentro del pod. Y si con un pod ya es un problema de memoria, con diez es el problema del prólogo: diez versiones de la verdad. Era hora de cumplir la segunda promesa.
Acto V: Diez pods, diez verdades
La idea era simple: que todas las réplicas lean y escriban la misma caché, en Redis. Pero “simple” es justo la palabra que suele venir antes de las semanas perdidas, así que antes de escribir una línea me tocó entender cómo cachea Next de verdad.
Cómo cachea Next (y por qué la intuición no alcanza)
La primera sorpresa: en Next 16 no hay una caché, hay tres.
| Caché | Qué guarda | Cómo se reemplaza |
|---|---|---|
| Incremental | Páginas prerenderizadas, ISR, respuestas de route handlers, fetch | Una clase en cacheHandler |
"use cache" | El resultado de funciones y componentes con "use cache" | Un handler registrado en un símbolo global de Next, configurable con cacheHandlers |
| Imágenes optimizadas | Las variantes que genera sharp | El mismo cacheHandler, pero solo si activas images.customCacheHandler |
Ese último flag, fácil de pasar por alto, confirmaba que las imágenes del MISS → MISS → MISS sí podían ir a Redis.
La segunda sorpresa fue cómo invalida Next. Mi intuición era: si todas las réplicas leen el mismo Redis, ¿para qué tendría que enterarse una de lo que invalidó otra? Están leyendo del mismo lugar. La intuición era correcta, pero le faltaba un dato: en Next, invalidar no es borrar una entrada.
- Por tiempo, Next nunca borra nada. Cada entrada guarda cuándo se creó, su
revalidatey suexpire. Al leerla compara la edad: si pasórevalidate, la sirve igual y la regenera en segundo plano; si pasóexpire, la descarta y renderiza en el momento. - A demanda (
revalidateTag,revalidatePath), Next tampoco toca las entradas. Anota “el tagpostsse invalidó a las 20:15:03” en unMapllamadotagsManifest, y al leer una entrada con ese tag que sea más vieja que esa hora, la trata como vencida.
Y ese tagsManifest vive en la memoria de cada proceso. Ahí está la trampa del Next clásico con varias réplicas: aunque las páginas se compartieran, la marca de “este tag se invalidó” se queda solo en el pod que recibió la llamada. Así que no hacía falta que nadie se enterara de nada: solo había que llevar a Redis también esas marcas.
flowchart TD
A["Pod que invalida"] -->|"anota el tag, sin borrar la página"| R["Redis"]
R -->|"otra réplica lee la página y la marca"| B["Otra réplica"]
B --> C["La página es más vieja que la marca: está vencida"]
Antes de construir, busqué si ya existía. Next tiene un adapter oficial para Bun, adapter-bun, con su propia caché sobre SQLite. Lo estudié a fondo y me llevé una técnica: usa el hook modifyConfig del adapter para inyectar el cacheHandler, y ese mismo módulo registra el handler de "use cache" al importarse, así que con una sola pieza cubre las dos cachés. Pero su propia documentación lo dice sin rodeos: “No hay Redis. No hay invalidación entre máquinas.” Resuelve otro problema, el de un solo servidor. Lo que yo necesitaba no existía.
Las reglas del juego
La caché se activa en runtime, con una variable de entorno: si existe REDIS_URI, el binario modifica el config de Next antes de arrancar y apunta todo a Redis; si no existe, se comporta exactamente como antes. El mismo artefacto sirve para todos los entornos, y para comparar con y sin Redis no hay que recompilar. Todas las claves llevan el buildId, para que en un rolling update un pod nuevo no sirva páginas renderizadas con los chunks de la versión anterior. Y si Redis se cae, la app sigue funcionando sin caché: cada operación devuelve un miss en vez de fallar, con un circuit breaker para que ninguna request se quede esperando un timeout.
Pero la decisión que más pesa es otra:
Decisión
Replicar lo que hace Next con .next/cache, no inventar un modelo de caché propio.
- El TTL solo limpia. Una entrada se guarda con TTL únicamente cuando tiene un
expirefinito, y ese TTL es el mismoexpire: pasado ese tiempo, Next la descartaría al leerla de todos modos, así que Redis solo se ahorra guardar basura. Las marcas de los tags no tienen TTL. - Las marcas de los tags en Redis. En la caché incremental, el handler trae de Redis las marcas de los tags de cada entrada y las carga en el
tagsManifestde Next, que decide con sus propias reglas si está vencida o expirada. En"use cache", en cambio, Next le deja esa decisión al propio handler, así que ahí sí repliqué esas reglas (areTagsExpiredyareTagsStalede Next) en un archivo chico,tags.ts. - Las páginas prerenderizadas se leen del binario con el propio código de Next cuando Redis todavía no las tiene, sin reimplementar su formato.
- Nada de caché en memoria delante de Redis. Ponerla en la memoria o el disco del pod es exactamente lo que estamos tratando de eliminar.
Por qué: cada regla que reimplemento es una regla que se puede desincronizar de Next en el próximo upgrade.
Costo que acepto: cada lectura cruza la red hasta Redis. Con una sola réplica eso probablemente sea más lento que el disco local. Lo dejo escrito antes de medir, para que nadie lo lea después como una derrota.
Quedaba un obstáculo para engancharlo. Next carga el cacheHandler con un import() de una ruta de archivo, y mi handler está compilado dentro del binario: no es un archivo. La solución fue un shim de una línea que el adapter escribe en $bunfs:
export default globalThis[
Symbol.for("next-compiler.incremental-cache-handler")
];
El binario registra la clase en ese símbolo al arrancar y apunta cacheHandler al shim. Antes de construir nada, lo validé con un binario mínimo: un import() del shim desde un ejecutable compilado devolvía la misma clase que estaba en el bundle. Diez líneas de experimento antes que doscientas de implementación, otra vez.
Vuelve el fantasma
Con dos réplicas del binario apuntando al mismo Redis, la primera prueba salió perfecta: la réplica A renderizaba una página ISR y la réplica B la servía desde Redis, con la misma marca de tiempo. Caché compartida, funcionando.
Después invalidé el tag de esa página en A. Y ninguna de las dos réplicas cambió nada. Ni siquiera A, la que había recibido la invalidación. Eso descartaba de entrada un problema de distribución: algo estaba roto incluso con una sola réplica.
Revisé Redis a mano. La marca del tag estaba escrita, con el formato exacto que usa Next, y la entrada de la página tenía guardado ese tag. Las marcas de tiempo contaban la historia completa:
página guardada: …234146 ms
tag invalidado: …234188 ms ← después
Según las reglas de Next, esa página tenía que contar como expirada. Pero nunca se volvió a renderizar. Agregué trazas a mis handlers, y ahí apareció lo raro:
RedisCache: get APP_PAGE …/isr/dbg-1 redis {
lastModified: 1791765335590,
marks: { "item-dbg-1": { stale: 1791765336147, expired: 1791765336147 } }
}
Mi handler cargaba la marca correcta en el tagsManifest, posterior a la entrada. Y Next la ignoraba. Solo había una explicación: Next estaba consultando otro tagsManifest.
flowchart TB
subgraph dos["Dos copias del mismo módulo"]
S["Servidor empaquetado"] --> MA["tagsManifest A"]
C["Chunks desde $bunfs"] --> MB["tagsManifest B"]
end
H["El handler escribe en A"] --> MA
N["Next lee B"] --> MB
¿Te acuerdas de las dos copias del Acto I? Te dije que iban a volver. En el binario hay dos copias de algunos módulos de Next: la que Bun empaquetó en el servidor y la que los chunks cargan desde $bunfs con mi resolver. Aquella vez nos salvamos porque Next guarda los AsyncLocalStorage en globalThis. Pero tagsManifest no: es un Map a nivel de módulo. Dos copias del módulo, dos Map. Mi handler escribía en uno y Next leía el otro.
Busqué en los chunks quién lo pedía, y ahí estaba: require("next/dist/server/lib/incremental-cache/tags-manifest.external.js"). Ese .external en el nombre es la clave: Next marca así justamente los módulos que tienen que existir una sola vez, compartidos entre el servidor y los bundles de cada ruta. Mi resolver rompía esa garantía. Hice la lista completa: la app usaba 16 módulos .external, y 6 guardaban estado a nivel de módulo, entre ellos las invalidaciones de tags, la caché LRU en memoria, los manifiestos ya cargados y el estado de instrumentation.ts.
Esto no era un bug de Redis. Era un bug del adapter desde el primer día, que también afectaba al binario sin Redis. Solo que nada lo había ejercitado todavía. La sospecha que taché en el Acto I nunca estuvo resuelta: tuve suerte con el primer módulo que miré.
Decisión
Una sola instancia de cada módulo .external: la que está empaquetada en el binario.
En el build, el adapter busca en el staging todos los require("next/dist/…external…") y genera un archivo con un require() diferido de cada uno, para que Bun los empaquete una sola vez. Cuando un chunk pide un .external, el resolver le entrega esa instancia en vez de cargar otra copia desde $bunfs.
Alternativa descartada: parchear solo tagsManifest, y dejar a los otros cinco esperando su turno para romperse en producción.
Costo que acepto: depende de una convención de nombres de Next, no de una API pública. Si Next la cambia, la duplicación vuelve en silencio.
Con eso, la invalidación funcionó entre réplicas, en los dos modos de Next:
A invalida con { expire: 0 } → B renderiza en el acto → A recibe la versión nueva desde Redis
B invalida con "max" → A sirve lo viejo y regenera atrás → B ya ve lo regenerado
Un bug latente no aparece cuando lo buscas: aparece cuando algo nuevo lo ejercita. La caché distribuida no trajo este bug, lo encontró. Y cuando lo encuentras, la pregunta no es “¿cómo arreglo este síntoma?”, sino “¿qué garantía rompí, y dónde más la rompí?”.
Redis se cae, y nadie avisa
La regla de “si Redis se cae, seguir sin caché” sonaba muy bien en papel. Las primeras pruebas le encontraron tres agujeros:
- El circuit breaker se abría solo. La primera petición llegaba antes de que terminara la conexión inicial, el comando fallaba y el breaker se abría: cinco segundos sin caché en cada arranque. Una conexión que todavía se está estableciendo no es una falla. Ahora es un miss silencioso, y el circuito solo se abre ante fallas reales.
- El cliente se rendía. Leyendo los tipos de
Bun.redisencontré que la reconexión automática se rinde después de 20 intentos y no vuelve a probar. En Kubernetes, si Redis tarda en volver, el pod quedaría sin caché para siempre. Ahora el adapter reintenta cada cinco segundos, indefinidamente. - El silencio. Al detener Redis, la app siguió sirviendo, como se esperaba, pero el log no dijo nada. El servidor sabía que estaba desconectado (por eso devolvía miss), pero no se lo decía a nadie.
Una app que corre sin caché sin avisar es exactamente la clase de falla silenciosa que me venía persiguiendo desde sharp. A esta altura ya no me sorprendía; me ofendía. Ahora el log registra la caída, la vuelta y el caso de arrancar sin Redis disponible.
Con todo arreglado, la prueba en la imagen distroless: dos réplicas del binario y un Redis en la misma red. El smoke test pasó completo con Redis. A renderiza una página ISR y B la sirve desde Redis; B invalida el tag y A renderiza la versión nueva. Y la que cierra el círculo del Acto IV: A optimiza una imagen (MISS) y B la pide (HIT). Lo que antes era MISS → MISS → MISS ahora se optimiza una vez y lo sirve cualquier réplica.
¿Redis cobra o paga?
Te dije que había dejado escrita una predicción: con una sola réplica, Redis probablemente sea más lento que el disco local. Tocaba comprobarla, con la misma suite del Acto IV y tres variantes intercaladas en la misma sesión: el binario sin Redis, el binario con Redis y Node clásico. Redis contó como parte del sistema, no como un regalo: su propio contenedor con sus propios límites, y uno nuevo y vacío por escenario.
Los números traían tres mejoras claras y un dato que no me cuadraba:
- Las imágenes, resueltas. El binario pasó de 6 mil a 13 mil peticiones por segundo, y ahora le ganaba a Node, que las servía desde su disco.
- La memoria de ISR salió del pod. En esta corrida, el pico bajó de 552 a 367 MiB, y las páginas pasaron a ocupar 124 MiB en Redis. El sistema usa casi lo mismo en total, pero con ocho réplicas esos 124 MiB se pagan una vez, no ocho.
- Una mejora que no busqué:
"use cache". El p95 bajó de 232 a 89 ms. La explicación está en la documentación de Next: su caché en memoria descarta las entradas apenas llegan arevalidate, porque regenerarlas en segundo plano suele ser trabajo perdido si es probable que se desalojen antes de que alguien las lea. Un almacén compartido no tiene ese problema, así que el handler de Redis las sigue sirviendo mientras se regeneran detrás. No lo diseñé para esto: salió solo, por replicar bien el modelo de Next.
El dato raro: mientras el binario servía 13 mil imágenes por segundo desde Redis, el contenedor de Redis usaba 4 % de CPU. ¿Trece mil lecturas por segundo con 4 % de CPU? Me olía a que Next tenía alguna capa en memoria escondida y que la mayoría de las peticiones ni llegaba a Redis. Si era así, el número no medía lo que yo creía.
En vez de especular, conté. Reseteé las estadísticas de Redis, mandé exactamente mil peticiones de la misma imagen y le pregunté a Redis cuántos comandos había recibido:
1.000 peticiones de imagen → cmdstat_get: calls=1000, usec_per_call=7.63
1.000 peticiones de una página ISR → cmdstat_get: calls=1000 · cmdstat_mget: calls=1000
Un GET por imagen; un GET y un MGET (las marcas de los tags) por página. No había ninguna capa escondida: Redis responde cada lectura en unos 8 microsegundos, y por eso casi no usa CPU. El número era bueno y era real. Cuando un número se ve demasiado bueno, no lo celebres ni lo descartes: mide el mecanismo que lo produce. Dos minutos con INFO commandstats valen más que una tarde de teorías.
¿Y la predicción? Equivocada, y me alegro. Redis no hizo más lento al binario de forma medible, ni siquiera con una sola réplica. Si no la hubiera escrito antes, ahora estaría diciendo “sabía que Redis iba a salir bien”.
De igual a igual
Esa corrida tenía una injusticia: el binario tenía Redis y Node no. Así que le di a Node la misma arquitectura, con un handler de Redis gemelo: las mismas claves, el mismo formato byte a byte y las mismas marcas de tags. Si un handler guardara las cosas mejor que el otro, estaría midiendo quién escribió mejor el handler, no Bun contra Node.
| Escenario | Binario + Redis req/s | Node + Redis req/s | Binario vs. Node | p95 binario / Node |
|---|---|---|---|---|
static | 3.722 | 2.100 | 1,8× | 11,7 / 29,4 ms |
dynamic | 659 | 277 | 2,4× | 75 / 158 ms |
cache | 506 | 255 | 2,0× | 90 / 170 ms |
api | 3.593 | 1.964 | 1,8× | 11 / 44 ms |
asset | 19.382 | 7.762 | 2,5× | 3,3 / 6,5 ms |
isr-new | 110 | 60 | 1,8× | 459 / 1.160 ms |
image | 13.746 | 8.010 | 1,7× | 4,0 / 6,1 ms |
Con la misma caché, la ventaja se mantiene: entre 1,7 y 2,5 veces más, con menos latencia en los siete escenarios. No venía de Redis, venía del runtime y del empaquetado. Y en la cola se nota todavía más: en ISR, el p95 de Node pasa el segundo.
Pero con un solo pod, Redis es una apuesta con costos visibles y beneficios invisibles. Su valor de verdad, que todas las réplicas vean lo mismo, solo existe cuando hay más de una. Y eso no se mide en mi Mac. Había que salir a la carretera.
Acto VI: La carretera
Kubernetes con un comando
Para levantar el entorno usé Tilt, por una razón que no tiene nada que ver con el rendimiento: que cualquiera pueda reproducirlo. Tilt es, más o menos, el docker-compose de Kubernetes: un Tiltfile declara qué imágenes construir y qué manifiestos aplicar, y tilt up levanta todo.
El entorno tiene tres piezas: next-bun (8 réplicas del binario con REDIS_URI), next-node (8 réplicas de Node con el handler gemelo) y un redis compartido. El cluster es un solo nodo de 10 CPUs, así que le di medio CPU a cada réplica y probé una variante a la vez, con la otra escalada a cero.
flowchart LR
k6["Job de k6, dentro del cluster"] --> svc["Service"]
svc --> pods["8 réplicas, 0,5 CPU cada una"]
pods --> redis["Redis compartido"]
Con 8 réplicas de 1 CPU, más k6 y Redis, estaría midiendo la saturación del nodo y no la capacidad de la app.
Al desplegar apareció algo que no esperaba medir todavía: varios pods de Node fallaron sus primeras sondas de readiness porque tardaban más en arrancar, y Kubernetes los tuvo que esperar. Los del binario estuvieron listos de inmediato. Cuando un autoscaler crea pods porque llegó tráfico, cada segundo de arranque es un segundo con menos capacidad de la que hace falta. Y el manifiesto de Node tuvo que repetir algo que el binario resolvió en el Acto II: HOSTNAME=0.0.0.0, porque el servidor standalone de Next escucha en esa variable y Kubernetes la llena con el nombre del pod.
La trampa del port-forward
Un detalle que conviene saber antes de medir cualquier cosa en Kubernetes: kubectl port-forward svc/next-bun no balancea. Aunque apuntes al Service, kubectl elige un solo pod al abrir el túnel y le manda todo a ese. Una prueba de carga por port-forward mide un pod, no ocho, y te lo dice con total tranquilidad.
Para que la carga se reparta, el generador tiene que estar dentro del cluster y pegarle al nombre del Service. Por eso k6 corre como un Job de Kubernetes, con el script montado desde un ConfigMap que crea el propio Tiltfile. Lo verifiqué contando qué pod atendía cada petición: 40 peticiones, repartidas entre los 8 pods.
Y antes del benchmark, la prueba que justifica todo el Acto V: 8 peticiones a la misma página ISR, a través del Service y por lo tanto en pods distintos, devuelven la misma marca de tiempo. Invalido el tag, y las 8 devuelven la nueva. En las dos variantes. Ocho pods, una sola verdad.
Ocho réplicas, misma CPU
La prueba que todo el proyecto venía postergando: las dos variantes con 8 réplicas, la misma caché en Redis, k6 dentro del cluster y dos repeticiones de la suite completa.
| Escenario | Binario req/s | Node req/s | Binario vs. Node | p95 binario / Node | CPU total binario / Node |
|---|---|---|---|---|---|
static | 9.585 (±4 %) | 3.730 (±2 %) | 2,6× | 23,5 / 57,9 ms | 361 / 360 % |
dynamic | 1.581 (±3 %) | 665 (±2 %) | 2,4× | 87,6 / 197,1 ms | 361 / 358 % |
cache | 1.398 (±1 %) | 583 (±2 %) | 2,4× | 94,2 / 208,1 ms | 361 / 359 % |
api | 9.196 (±1 %) | 3.910 (±1 %) | 2,4× | 29,7 / 58,7 ms | 367 / 366 % |
asset | 23.024 (±2 %) | 21.004 (±2 %) | 1,1× | 7,2 / 7,6 ms | 201 / 357 % |
isr-new | 339 (±5 %) | 207 (±2 %) | 1,6× | 326 / 452 ms | 335 / 358 % |
image | 34.257 (±1 %) | 19.854 (±0 %) | 1,7× | 4,9 / 8,8 ms | 328 / 354 % |
Cero errores en las 28 corridas, y una variación entre repeticiones de 0 a 5 %. Por primera vez en todo el proyecto, la banda de ruido es angosta y está medida, no estimada.
Pero la columna que importa es la última. En casi todos los escenarios, las dos variantes usan alrededor de 360 % de CPU: ocho pods al ~90 % de su límite de medio CPU. Las dos están saturando exactamente el mismo presupuesto. Eso convierte la comparación en la más limpia de todo el proyecto: la diferencia de throughput es, directamente, eficiencia por CPU.
Con la misma CPU, el binario atiende entre 2,4 y 2,6 veces más peticiones en todo lo que renderiza o ejecuta código del servidor. Dicho como lo preguntaría quien paga la factura: para atender la misma carga, Node necesitaría unas 2,4 veces más CPU. Más réplicas, más nodos, más plata. Y la latencia acompaña: el p95 de Node es más del doble en casi todos los escenarios.
Las dos excepciones también enseñan algo:
assetempata en throughput, pero no en eficiencia. El binario sirvió 23 mil peticiones por segundo usando 201 % de CPU: no estaba saturado, el cuello de botella era el generador de carga. Node llegó a 21 mil saturando la suya. Sirven lo mismo, pero el binario lo hace con poco más de la mitad de la CPU.- ISR es la ventaja más chica (1,6×), y tiene sentido: es el escenario que más escribe en Redis, y ahí parte del tiempo es red, no runtime. En 40 segundos, el binario dejó 25.004 páginas en Redis y Node 15.963.
¿Y la memoria? Medí la memoria privada de cada proceso, sin contar la caché de archivos del sistema operativo. Las dos variantes arrancan igual: 48 contra 47 MiB por pod. Bajo carga, el binario usa más, entre 1,2 y 1,9 veces más según el escenario, mientras atiende entre 1,6 y 2,6 veces más peticiones. Por petición gasta menos; en valor absoluto, gasta más. La comparación justa, las dos a la misma tasa fija de peticiones, sigue pendiente.
La letra chica, para que nadie cite esto sin ella: es un solo nodo, con las réplicas, Redis y k6 en la misma VM; el generador limitó los escenarios más livianos, como mostró asset; y es una app de pruebas, sin base de datos ni llamadas a otros servicios. En una app real, parte de cada petición es espera de red, y ahí el runtime pesa menos.
Pero la respuesta a la pregunta del prólogo ya está sobre la mesa. Un binario y una caché: las dos promesas, cumplidas, en ocho réplicas que ven la misma verdad y atienden más del doble con la misma CPU.
Epílogo: lo que no te dicen los benchmarks
Antes de que salgas corriendo a reemplazar tus imágenes de Node, te debo la parte honesta. Este diseño se apoya en cosas que nadie me prometió: rutas internas de Next, el runtime de Turbopack, la convención .external, el tagsManifest, y el comportamiento de $bunfs en Bun, que no está documentado como contrato. Además, parchea require y fs.createReadStream para todo el proceso. Lo que me protege son tres cosas: las versiones fijadas, el smoke test en cada upgrade y los experimentos mínimos, que me permiten revalidar cada ley del sistema en minutos.
Por eso, hay casos en los que no usaría esto:
- Si nadie en el equipo va a mantener el adapter en cada upgrade de Next.
output: "standalone"con Node es aburrido, y en producción “aburrido” es un elogio. - Si la app depende de muchos paquetes nativos, o de leer archivos del disco en runtime.
- Si el costo no te duele: pocos pods, sin autoscaling agresivo, sin presión en la factura. Optimizar algo que no te duele no es ingeniería; es un hobby. Está bien tenerlo, pero no lo pongas en el roadmap.
Y queda trabajo por delante. Falta la prueba de memoria a la misma tasa de peticiones, la única comparación de RAM realmente justa. Las páginas del build son inmutables e idénticas en todas las réplicas, así que merecen una caché en memoria que no rompe ninguna regla. Quiero extraer sharp durante el build de la imagen, para que un pod con readOnlyRootFilesystem no necesite un directorio escribible. Y la lista de paquetes nativos tiene que dejar de ser un ["sharp"] escrito a mano.
Lo que aprendí (y lo que quiero que te lleves)
Cada una de estas la aprendí a golpes. La idea es que tú te la lleves sin los golpes.
- Aprendí que los stubs del Acto III eran aceptables no porque no se fueran a romper, sino porque, al romperse, el binario solo pesaba más o fallaba con un mensaje claro. Llévate esto: cuando aceptes un acoplamiento, diseña cómo va a fallar, no solo cómo va a funcionar.
- Aprendí que
sharp, los estáticos, la caché de imágenes y Redis podían fallar sin decir una palabra, y que en todos los casos lo primero fue lograr que el error se viera. Llévate esto: antes de arreglar una falla silenciosa, hazla hablar. - Aprendí que un binario de quince líneas me respondía en dos minutos lo que mirando el servidor de Next me habría tomado horas. Llévate esto: cuando un sistema grande falla, aíslalo con el experimento más chico que puedas armar.
- Aprendí que la caché funciona porque sigue las reglas de Next, y que por eso hasta mejoró algo que no busqué. Llévate esto: antes de inventar un modelo propio, replica el de la plataforma.
- Aprendí que
.externalno es una API sino una promesa: “de esto hay una sola copia”. Romperla no dio errores; dio un comportamiento distinto, en silencio, mucho después. Llévate esto: respeta las garantías implícitas, aunque nadie las haya escrito en la documentación. - Aprendí que mi predicción sobre Redis estaba equivocada, y que un número demasiado bueno se explicaba contando comandos. Llévate esto: escribe la predicción antes de medir, y cuando un número parezca demasiado bueno, mide el mecanismo que lo produce.
Ahora te toca a ti
El código está en github.com/luiscib3r/next-compiler, con la app de pruebas, el smoke test, los benchmarks y el entorno de Kubernetes:
bun install
bun run build # next build + el adapter → build/server
PORT=8080 ./build/server
bun run smoke # recorre todas las páginas de la app de pruebas
Con docker compose up lo tienes con Redis, y con tilt up, en Kubernetes con ocho réplicas. Rómpelo, cámbiale la versión de Next, ponle tu app. Y si encuentras una ley del mundo $bunfs que no descubrí, cuéntamela.
Yo, por mi parte, sigo. La última vez que lo compilé, el binario dijo ✓ Ready, hice el curl y me devolvió un 200.