Sci Math && Code
Blog

Next.js for Kubernetes: one binary, one cache, and 2.4× more traffic on the same CPU

I compiled Next.js into a single Bun executable and gave it a Redis cache shared by every replica. Along the way I learned how to hunt silent failures, how to distrust my own benchmarks, and when to walk away from an idea that works.

software-architecture · nextjs · bun · kubernetes · redis

Next.js for Kubernetes: one binary, one cache, and 2.4× more traffic on the same CPU

✓ Ready

That is what the binary said: a single file with a complete Next.js application and its runtime inside. No Node.js, no node_modules. I opened another terminal and ran the first curl:

HTTP/1.1 500 Internal Server Error

About ten seconds passed between ✓ Ready and that 500, and in those ten seconds I had already opened LinkedIn to announce that I had compiled Next.js. I closed it slowly, the way you do when you hope nobody saw 😄.

By the end of this story you will see that same binary running as eight replicas inside Kubernetes, with a single cache in Redis, serving 2.4 times more requests than Next.js on Node.js with the same CPU. We are going to get there the way I did: the same mistakes, in the same order, with the same “now what?” look on my face.


Why

Next.js deploys beautifully on Vercel, and other providers have adapters tuned for their platform. On Kubernetes, the recipe is an image with Node.js, node_modules, and next start. The size of those images is already an economic offense, but the cache is worse: each pod keeps its own, on its own disk. Ten replicas, ten versions of the truth. Invalidate a page on one pod and the other nine keep serving the old one.

At the same time, there is something I admire about compiled languages: the binary. In Go I embed a SPA with embed and the API and the frontend travel in a single file. With Leptos and Dioxus, in Rust, I got as far as SSR in a single binary. But an elegant deploy does not justify abandoning Next.js and the React ecosystem. What was left was to bring that capability to the ecosystem.

The plan

Next.js 16 ships Build Adapters, a hook at the end of next build that hands you everything the build produced so you can package it however you want. Bun compiles JavaScript into a single executable, runtime included. The hypothesis:

An adapter that puts an entire Next.js app into a single Bun binary, on a distroless image (no Node.js, no node_modules, no shell), with every replica sharing a single cache.

From the start there was a tension you will see in every chapter: the more aggressive I got about binary size, the more I would couple myself to the guts of Next and of Bun, two projects that move fast. So besides the problems, I am going to show you every important decision: what I chose, what I discarded, and what cost I accepted. That way, if something breaks in the next version, you could fix it yourself.

This is not a tutorial. This is a long debugging session, and you are sitting next to me. We are going to hunt failures that leave no trace in the log, a deadlock, and a bug that slept from day one. Almost every problem was understood with an experiment of ten or fifteen lines, and I am leaving them here so you can run them. The code is at github.com/luiscib3r/next-compiler, with Next.js 16.4, React 19.3, and Bun 1.4.3.

Open a terminal. Let us start with that 500.


Act I: A world without file descriptors

Before the 500, a quick look at how the adapter works, because everything that follows depends on one detail. When next build finishes, Next hands the adapter an outputs object with every route in the app and the files that route needs. The adapter does three things:

  1. Staging: it builds a folder with everything the server will need in production, and nothing else: the .next/server chunks, the static files, public/, and the pieces of node_modules that are actually used. It is the bed where you lay the clothes out before you pack the suitcase: what is on the bed goes in; what is not, stays home. I am going to call that folder the staging, and its contents are exactly what ends up inside the binary.
  2. Generate: it writes a small startup program that calls Next’s startServer and serializes the configuration to JSON, so the binary does not have to load next.config.ts at runtime.
  3. Compile: Bun.build with compile packages that program into an executable and embeds the staging folder inside it.
flowchart TD
    A["next build"] --> B["outputs: routes and files"]
    B --> C["Staging: only what production uses"]
    B --> D["Startup: startServer + config as JSON"]
    C --> E["Bun.compile embeds the staging"]
    D --> E
    E --> F["One executable"]
    F --> G["$bunfs/root, visible only inside the process"]

That last point is the detail. Embedded files live in a virtual filesystem that Bun exposes inside the process, at /$bunfs/root/. From your code you can readFileSync("/$bunfs/root/app/package.json") and it works. But that filesystem exists only inside the Bun process. The operating system has no idea it is there.

$bunfs is like another planet: it looks a lot like a normal filesystem, but it has its own laws of physics. The first one, call it law zero, I had already found before the binary ever got as far as saying ✓ Ready: in there, Bun evaluates every .js file as an ES Module, and the chunks Next generates for the server are CommonJS (require, module.exports). The fix was a require.ts that overrides Module.prototype.require and, for $bunfs paths, runs each file as CommonJS with vm.runInThisContext and the classic wrapper (function (exports, require, module, __filename, __dirname) { ... }).

With that it compiled, it started, and it returned 500. On to the other laws.

Law 1: nobody looks in node_modules here

The 500 log said this:

⨯ 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

First, the obvious question: does the file exist? Yes. It was in the staging, so it was embedded in the binary.

But look at how the chunk asks for it: by package name (next/dist/...), not by path. My suspicion was that Bun’s resolver does not walk node_modules when the file requesting the module lives inside $bunfs. I could start reading the Next server, which has a thousand moving parts, or build the smallest experiment possible. I chose the second, and I am leaving it here so you can run it.

Try it yourself

A fake node_modules with a foo package, embedded in a binary that tries to resolve it from the inside. Before you run it, move the app folder: that way you know that whatever works is working from $bunfs and not from disk.

mkdir -p app/node_modules/foo app/sub
echo '{"name":"foo","main":"index.js"}' > app/node_modules/foo/package.json
echo 'module.exports = "I am foo";' > app/node_modules/foo/index.js
echo 'module.exports = "I am 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'

Relative and absolute paths: fine. Package names: not fine. Node resolves a require("foo") by walking up folder by folder looking for node_modules/foo, and inside $bunfs Bun simply does not do that walk. And if you change require.resolve(id) to require(id), you run into law zero: module is not defined, because that .js was evaluated as ESM.

The fix: if Bun will not do it, I will. I wrote my own resolver and handed it to every chunk as its require:

  1. If it is a Node module (fs, path…), pass it to the original require.
  2. If it is a relative or absolute path, resolve it and try extensions.
  3. If it is a package name, walk up the directories looking for node_modules/<package>, read its package.json, and honor exports (with the node, require, and default conditions) and main.

There was one more trap. The next error was Cannot find module 'next/dist/compiled/source-map', and that file existed too. Except it is not a file: it is a folder with its own package.json that says "main": "source-map.js". Node reads that main when a require points at a folder, and my resolver only looked for index.js. One more line, and at last:

HTTP/1.1 200 OK
x-nextjs-cache: HIT

So, how hard could it be to reimplement Node’s module resolution algorithm? Harder than anyone would admit in a standup.

Decision

$bunfs is the only source of the app’s files, and modules are resolved at runtime with a custom resolver.

Discarded alternative: extract the app to disk on startup and let Bun resolve the way it always does. Simpler, but it requires a writable disk on every pod and duplicates everything already inside the binary.

Cost I accept: maintaining a resolver compatible with Node’s. If a package uses a form of resolution I do not cover, it will fail.

Law 2: file descriptors do not exist here

The page loaded, but the CSS, the images in public/, and the favicon returned 500. And this time the log was empty. Not a single error. The server failed with the serenity of a Tibetan monk.

Next serves static files with the send package, which does an fs.stat and then an fs.createReadStream. I went back to the minimal-binary technique, this time trying different fs APIs against an embedded file.

Try it yourself

Use the same build.ts as before, with any app/style.css.

// 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

The pattern is obvious: file descriptors do not exist in $bunfs. Anything that needs to open an fd fails; reading the whole file or calling stat works. send needs createReadStream, so it blows up, and because Next catches the error and responds with a generic 500, nothing is left in the log.

fs is a shared module: if you modify it, the whole process sees it, including the Next code that Bun packaged into the binary. So I overrode createReadStream only for $bunfs paths, building the stream from readFileSync and honoring start and end, so that requests with Range (videos, for example) keep working:

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)]);
};

I could have written my own static file server, but Next already handles cache headers, ETags, and Range, and I would have had to reimplement them. The cost of this patch is that every static file is read fully into memory before the response. For CSS and icons that does not matter; for a 200 MB video in public/, it does. But that kind of file should come from a CDN in production anyway, so I accepted the cost.

Law 3: the API does not tell you everything

With all of that fixed, /favicon.ico was still failing:

Failed to load external module next/dist/compiled/next-server/app-route-turbo.runtime.prod.js

This time the file was not in the staging. To understand why, I made the adapter dump the outputs object Next hands it into a JSON file. The answer was in there: the App Router favicon is technically a route handler, but because Next prerenders it at build time, it reports it as a STATIC_FILE. The adapter walked routes and their dependencies, so it never looked at that handler’s. But at runtime, the server does load the module.

Technically correct, the best kind of correct, and also the most useless kind when you are debugging.

The fix was to stop relying on the API alone. Next to every server file, Next leaves an .nft.json (from Node File Trace) with the list of files that module needs. The staging now also walks every one of those .nft.json files and copies whatever is missing. The cost: those traces are an internal detail of Next, not a public contract. If they change format in an upgrade, the adapter can leave files out. You will see what protects me from that.

The suspicion that wasn’t (yet)

There was something that had worried me from the start and that, curiously, was not failing.

Turbopack chunks request modules with names like work-async-storage.external.js. They are instances of AsyncLocalStorage, the mechanism Next uses so that headers(), cookies(), or after() know which request they belong to. The server and the chunks have to share the same instance: if each had its own, headers() would stop working.

And the binary has two copies of that module: the one Bun packaged inside the server, and the one my resolver loads from $bunfs. I built dynamic pages that read headers and cookies, and a route handler with after(), and compared them against next start. They behaved the same. Why? Because of this, in Next’s code:

const workAsyncStorageInstance =
  getOrCreateGlobalAsyncLocalStorage("work-async-storage");

Next stores those instances on globalThis, precisely so they survive the module being loaded more than once. Two copies of the module, one instance. I crossed the suspicion off the list and moved on.

Remember this thing about the two copies. It is going to come back, and it will not be so easy to resolve.

A safety net

By this point, testing with curl by hand was not enough anymore. But there was a more important reason to automate: every time I upgrade Next or Bun, the adapter can break from the inside in a thousand ways I do not control. I cannot avoid that risk, but I can detect it in minutes. In Building Evolutionary Architectures, Neal Ford, Rebecca Parsons, and Patrick Kua call this a fitness function: a test that protects a characteristic of the architecture, not a feature. Here, the characteristic is that the binary behaves exactly like next start.

So I built a test site inside the project, where each page exercises something the binary has to support: static and dynamic rendering, streaming, "use cache" with tags, ISR, Server Actions, redirects and rewrites, proxy.ts, next/image, Open Graph images, and route handlers. A smoke test walks every route and checks status, content, and headers:

bun run smoke                         # against the binary
bun run smoke http://localhost:3000   # against next start

The most important rule: before running it against the binary, I ran it against next start. If a test fails on the official server, the problem is in the test, not in the adapter. Validate the ruler first, then measure.

Against next start, everything passed. Against the binary, everything except two tests.

Law 4: here process.cwd() does not point at your app

The first of those two failures was a page that read a JSON file with fs.readFile(join(process.cwd(), "data/runtime.json")), a very common pattern. In the binary, the file is embedded at /$bunfs/root/app/data/, but process.cwd() is the real directory the process was launched from.

I could have patched process.cwd(), but that would have been one more global patch, changing the meaning of an API a lot of code takes for granted. Instead I set a rule: in an app that lives in a binary, code does not read files from disk on purpose. If you need static data, you import it (import data from "./data.json") and it ends up inside the bundle. It is the first convention the app has to follow to run in the binary, and that is why it is written down. Not every technical problem needs a technical solution. Sometimes it needs a rule, and an explicit rule is better than an invisible patch someone will discover in production.

The other failure was Open Graph images, and I was going to leave it for later. But before I moved on, something worse than a red test happened: I realized one of the green tests was lying to me.

I asked /_next/image for a 32-pixel image in webp. next start returned a 32×32 webp. The binary returned the original 64×64 PNG, with status 200 and not a single line in the log.


Act II: The descent

Next uses sharp to optimize images and, if sharp fails to load, it serves the original image without saying so. It is like ordering a decaf and getting a regular because they ran out of decaf, except nobody tells you. Technically, you got coffee.

My smoke test had not caught it because it only checked that the response was image/*. I tightened it to require webp, and from there an idea stuck with me that keeps coming back through this whole story: silent failures are the worst ones. The system “works”, but it is not doing what you think.

What does not fit in the suitcase

sharp is not pure JavaScript. It has two parts:

@img/sharp-linux-arm64/lib/sharp-linux-arm64-0.35.5.node   ← the JS ↔ C bridge (0.5 MB)
@img/sharp-libvips-linux-arm64/lib/libvips-cpp.so.8.18.7   ← the library that does the work (18 MB)

The .node file is loaded with dlopen, an operating-system call, and that brings two requirements:

  1. It has to be a real file on disk. The operating system maps it into memory as executable code, and you already know $bunfs exists only inside the Bun process.
  2. It looks for its libraries on paths relative to itself. readelf shows that the .node has an RPATH baked in:
RPATH: $ORIGIN/../../sharp-libvips-linux-arm64/lib

$ORIGIN is the folder where the .node lives. If libvips is not two levels up, on that exact path, dlopen fails. On macOS it is the same idea with different names: @rpath and @loader_path.

With a minimal binary I found something curious: Bun does embed the .node, and it even detects and embeds libvips. When it loads it, it extracts the .node to a temp file (/tmp/.bun-xxxx.node) and dlopens it. But it does not extract libvips, so the .node sits alone in /tmp, looking for its library on a path that does not exist:

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 packed the box correctly and, at delivery time, handed over the remote and left the television in the truck.

The detour

The first idea was to sidestep the problem. sharp has a WebAssembly variant (@img/sharp-wasm32): no dlopen, no native libraries, you only have to read a .wasm, and readFileSync works in $bunfs. It looked perfect.

When I tried it, the binary hung. It did not fail: it hung. And that is where the most entertaining investigation of the whole project began.

First clue: it also hung in Bun without compiling, while it worked in Node. The problem was not the binary or $bunfs. It was Bun with this WASM.

A false positive: at one point I thought it was already working. To force the WASM path, I had deliberately broken the native module load by intercepting Module._load… but in Bun, require does not go through Module._load. The hook never fired and I was using the native build without knowing it. I found out because a log I put inside the hook never printed. I repeated everything in an isolated node_modules that had only the WASM variant. From then on, before I believe a “it works”, I check that the path I think ran is the path that actually ran.

Narrowing it down: the module loaded in 73 ms, but the first async operation (metadata()) blocked the main thread forever, at 0% CPU. Not even a 3-second setTimeout ever fired. That smells like a deadlock: two threads waiting on each other with the patience of a meeting that could have been an email.

The code Emscripten generates (the glue that comes with the WASM) uses workers as threads, so I started discarding hypotheses with ten-line experiments:

HypothesisExperimentResult
Workers do not start if the main thread is blockedWorker + Atomics.wait❌ Works in Bun
Bun cannot send a WebAssembly.Module and a shared Memory to a workerpostMessage({ memory, module })❌ Works
postMessage is not delivered if the sender is blockedpostMessage + Atomics.wait❌ Works
The worker starts, but never receives the message that carries the WASMTraces inside the Emscripten glue✅ Confirmed

I wrote the traces with fs.writeSync(2, ...) and not with console.log, because a worker’s console.log goes through the main thread… which was blocked. In Node, the worker received the message the WASM passes it. In Bun, it never did.

The cause was in these lines of the glue, which run inside the worker:

if (!globalThis.postMessage) {
  // am I not in a Web Worker?
  parentPort.on("message", (msg) => globalThis.onmessage?.({ data: msg }));
  globalThis.postMessage = (msg) => parentPort.postMessage(msg);
}

The glue asks: “does a global postMessage exist?”. If it does not, it assumes it is in a Node worker and subscribes to parentPort. If it does, it assumes it is in a browser Web Worker and waits for messages on onmessage.

Try it yourself

Save it as worker.mjs and run it with node worker.mjs and with bun worker.mjs.

import { Worker, isMainThread, parentPort } from "node:worker_threads";

if (isMainThread) {
  const worker = new Worker(new URL(import.meta.url));
  worker.postMessage("hello");
  setTimeout(() => process.exit(0), 500);
} else {
  console.log("typeof globalThis.postMessage:", typeof globalThis.postMessage);
  parentPort.on("message", (m) => console.log("parentPort received:", m));
  globalThis.onmessage = (e) => console.log("onmessage received:", e.data);
}
--- node
typeof globalThis.postMessage: undefined
parentPort received: hello

--- bun
typeof globalThis.postMessage: function
parentPort received: hello

Bun exposes a global postMessage in node:worker_threads workers too, but it does not deliver their messages to onmessage. The glue thinks it is in a browser, skips the parentPort subscription, and waits for a message that never arrives. Meanwhile the main thread waits on the worker. Deadlock.

I changed that condition to if (true) in a copy of the glue, and sharp on WASM produced the webp in 44 ms. Cause confirmed.

Knowing when to walk away

I had the cause and I had a fix that worked. And I discarded it anyway.

Decision

Do not continue down the WebAssembly path.

Why: integrating it meant patching a third-party package, building a special startup for its workers (Emscripten creates the worker pointing at its own file, which inside the bundle would be the entire binary), and making that worker work without my require hooks. Three more fragile pieces to maintain on every upgrade, to solve something a CDN is going to do in production.

What I kept anyway: the cause, documented and reproducible in twelve lines. It is a real incompatibility between Bun and Node, and if someone needs it tomorrow, the analysis is already done.

Knowing when to walk away from a path that works is wisdom too. And an investigation is not wasted time because it ends in a “no”.

The fix: unpack only what does not fit

I decided to solve native sharp, but directly on the real target: Linux, in a distroless image. I built a two-stage Dockerfile: the build on the official Bun image (oven/bun) and the runtime on gcr.io/distroless/cc-debian13, which brings the minimum Bun and sharp need in order to run. Before I got to sharp, a rock showed up.

The server was listening on 127.0.0.1. Inside a container, traffic arrives on the pod’s network interface, not on loopback. It now listens on 0.0.0.0, with a HOST variable to change it. One caveat: I do not use HOSTNAME, the way Next’s standalone mode does, because Kubernetes sets HOSTNAME to the pod name in every container, and the server would end up listening only on the pod’s IP. Breaking parity with Next on that point was a conscious decision.

Before designing something of my own, I looked at how others had solved it. next-bun-compile does something similar and had already hit this problem. Its approach is to extract everything to disk on startup: chunks, node_modules, and static files. After that, Next runs like a normal next start. It is a valid decision, one that favors simplicity in exchange for always needing a writable disk and duplicating the app. I wanted to keep serving everything from $bunfs, so I kept its central lesson (the addon needs a real disk) and applied it narrowly: extract to disk only the native packages, and only the first time someone asks for them.

flowchart TD
    subgraph Build["Build (adapter)"]
        B1["Collect sharp + dependencies"] --> B2["Filter by platform<br/>(os, cpu, libc)"]
        B2 --> B3["Copy into the staging<br/>+ write .native.json"]
        B3 --> B4["Mark sharp as external"]
    end
    subgraph Runtime["Runtime (the binary)"]
        R1["Next: require('sharp')"] --> R2{"Native<br/>package?"}
        R2 -- no --> R3["$bunfs resolver"]
        R2 -- yes --> R4["First time: copy<br/>$bunfs → /tmp/next-native-id"]
        R4 --> R5["require from disk"]
        R5 --> R6["dlopen finds libvips<br/>thanks to $ORIGIN"]
    end
    Build --> Runtime

At build time, the adapter starts from sharp and gathers its dependencies. The optionalDependencies are the prebuilt binaries, one per platform, and each one declares where it works ("os": ["linux"], "cpu": ["arm64"], "libc": ["glibc"]). The adapter compares those fields with the build machine and throws away what does not apply: on Linux, Bun also installs the musl variants, which are useless on glibc. It copies what remains into the staging, writes a manifest (.native.json) with an id that changes on every build, and marks sharp as external so Bun does not bundle it.

At runtime, the require hook recognizes sharp. The first time, it copies the packages in the manifest from $bunfs to /tmp/next-native-<id>/node_modules/ (or wherever NATIVE_MODULES_DIR says). With the node_modules layout on disk, Bun’s normal resolver works and the .node file’s RPATH finds libvips where it expects it. And if the app never optimizes an image, nothing is written to disk.

Two details cost me more than they look. The first is that the copy cannot be done with cpSync, because cpSync opens the files and, as we learned in the second law, $bunfs has no file descriptors. So I copy file by file, with readFileSync + writeFileSync.

The second is more treacherous. My hook works like this: every time someone does require("sharp"), it intercepts the call, extracts the packages to disk, and loads sharp from there. The problem is in that “loads sharp from there”. If I write require("sharp") again in order to load it, my own hook intercepts it again, which calls require("sharp") again, which intercepts again… until the stack blows up. Nothing humbles you quite like a hook that calls itself.

The way out is to stop asking for it by name. After extracting, I resolve sharp to the absolute path of the file on disk and require that path:

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);

The hook only intercepts native package names (sharp or sharp/...). A path like /tmp/next-native-<id>/node_modules/sharp/lib/index.js does not match, so it passes through and Bun’s normal require loads it, which can now find everything on disk.

Finally, because Next swallows sharp’s errors, the runtime now logs the cause when a native package fails to load. The silent failure from the first scene can no longer happen in silence.

Decision

Extract only native packages to disk, lazily, into a configurable directory.

Discarded alternatives: extract the whole app on startup (always needs a writable disk and duplicates the app); copy the native libraries next to the binary in the Dockerfile (it stops being a single artifact); sharp on WebAssembly (you already saw why).

Cost I accept: if the app uses sharp, the pod needs a writable directory that allows executing code. At least it is disposable: if the pod dies, the next one rebuilds it on its own. And for now, the list of native packages is manual: ["sharp"].

The filesystem has opinions too

On Kubernetes it is common to run with readOnlyRootFilesystem, so I tried several scenarios in Docker:

ScenarioResult
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 + a volume at NATIVE_MODULES_DIR✅

The third one is the interesting one. Docker mounts --tmpfs as noexec by default, and Linux will not map executable code from a noexec filesystem. On Kubernetes, a normal emptyDir is not noexec, but some security policies force it. If that happens to you, the pod now tells you so in plain language.

Native addons are not “just another file”. They have operating-system requirements (a real file, execute permission, relative paths) that no JavaScript layer can sidestep.

The last red test

One failure from Act I was still open, the Open Graph images:

Failed to load external module next/dist/compiled/@vercel/og/index.node.js:
TypeError [ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING]

next/og is published as ESM, so the Turbopack runtime loads it with import() instead of require(). And that runtime is one of the files my require.ts runs with vm.runInThisContext. Code that runs inside vm has no import() of its own: you have to pass it a function that resolves the import, in the importModuleDynamically option. In Node that requires an experimental flag; in Bun, a function of your own works as is. So I resolve the name with the same $bunfs resolver and import() the absolute path.

The nice part is that here law zero of the $bunfs world, the one that forced us to write the whole CommonJS patch, works in our favor: Bun evaluates $bunfs .js files as ESM, and this file is ESM. What used to be an obstacle is exactly what we needed.

And a mystery got solved along the way: the Open Graph route’s traces included sharp. It turns out @vercel/og does import("sharp") as an optional rasterizer: if it is there, it uses it, and if not, it uses resvg on WebAssembly.

With that, the smoke test passed in full, on the Linux distroless image.

A single file, no Node.js, no node_modules, no shell, doing everything next start does. I felt invincible for a few minutes. Then I measured the binary: 170 MB. The Docker image, 208. I had started all of this to run Next.js more efficiently than with Node, and I still had no idea whether I had pulled it off.


Act III: The diet

Before cutting anything, I measured what the binary was made of. If you skip this step, you end up optimizing what you think weighs a lot, and I had my bet: Next duplicated, one copy packaged into the server and another in $bunfs. Spoiler: that duplication weighed less than 5 MB.

PartSize
Bun runtime88 MB
$bunfs (the embedded app)61 MB
Server JS bundle~21 MB
Total170 MB

The Bun runtime is the floor: it cannot be reduced. The other two parts were left.

The whole kitchen, packed for a picnic

I asked Bun for a metafile of the bundle (the record of what went in, and why) and grouped it by package. The production server was carrying webpack (2.4 MB), babel (2.7 MB), Next’s devtools, postcss, cssnano, caniuse-lite, a 2.3 MB font-metrics JSON, and even the MCP server that next dev uses.

Instead of pulling packages out one by one, I computed the shortest import chain from the entry point to each of them. Almost everything came in through two doors:

server.ts
└─ next/dist/server/lib/start-server.js
   └─ next/dist/server/lib/router-server.js
      └─ router-utils/setup-dev-bundler.js      ← door 1
         └─ dev/hot-reloader-webpack.js
            └─ compiled/webpack/webpack.js

server.ts
└─ next/dist/server/next.js
   └─ dev/next-dev-server.js                   ← door 2
      └─ next-devtools/...

And in Next’s code, both sit behind a condition:

if (opts.dev) {
  const { setupDevBundler } = require('./router-utils/setup-dev-bundler');

The binary starts with dev: false, so those branches never run. But a bundler does not know which branches will run: it follows any require it can see. I was packing the whole kitchen for the picnic, in case someone felt like compiling something in production.

I tried to exclude them with --external, but --external matches against the text of the require, and here they are relative paths. It did not work in any of its forms. What did work was a Bun plugin that, when the bundler loads those two files, replaces them with a 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");`,
  };
});

The stub has no requires, so Bun does not follow the path toward webpack or babel. The server bundle dropped from 18.6 MB to 4.8 MB.

Remember the tension from the prologue, the one about how the smaller I make the binary, the more I couple myself to Next’s guts? This is the decision where it shows the most. router-utils/setup-dev-bundler.js is not a public API: it is an internal path Next can move tomorrow. So the question was not whether it could break, but how it would break.

Decision

Replace Next’s two development modules with stubs at build time.

Cost I accept: coupling to internal paths in Next, which an upgrade can move or rename.

How it fails: safely, in both directions.

  • If Next moves the file, the stub simply does not apply: the binary weighs 14 MB more again, but it still works.
  • If the production server ever did need one of those modules, the stub throws an error that says exactly what happened, and the smoke test catches it before it reaches a pod.

That property is what makes the coupling acceptable. I do not mind an optimization that stops applying. I would mind it breaking something in silence.

What was left over in the suitcase

$bunfs had extra luggage too:

  • The musl variants of sharp (19 MB). They came in through the Open Graph route’s traces, because of that optional import("sharp") in @vercel/og, and they did not pass through my platform filter. The staging now deletes the packages the filter discards, no matter which door they came in through.
  • The source maps (12 MB). They are not used at runtime. The adapter is for production on Kubernetes and nothing else, so anything that is not needed to run there comes out.
  • The .nft.json files (0.5 MB). They are only useful during the build.

And one more: if the app declares images.unoptimized: true in next.config (because a CDN optimizes the images), Next never uses sharp, so the adapter leaves it out of the binary along with its libraries: about 20 MB less. I like where the control sits: it is not a new adapter option someone has to discover, it is the configuration the app already declares. If the app says it does not optimize images, the binary does not ship the tool for doing it.

BeforeAfterWith images.unoptimized
Binary170 MB124 MB104 MB
Docker image208 MB163 MB143 MB
Compressed image (what a pull downloads)80 MB65 MB—

The compressed image drops less, proportionally, because what we removed (JavaScript, source maps) compresses very well. Of those 65 MB, about 42 are the Bun runtime: we are already close to the floor.

Against Node, for real

A loose number says nothing. The question a real team asks is not “how much does it weigh?”, it is “this, or Next.js on Node.js the way everybody deploys it?”. So I built a second Dockerfile in the classic shape. And there is a trap here that is very easy to fall into without meaning to.

Decision

Compare against the best reasonable Node.js configuration, not against a straw man.

It is very easy to beat a 1 GB node:24 image with npm install inside it, and it is also useless: nobody serious deploys that to production. So the Node image uses output: "standalone" (the mode Next recommends for Docker, which copies only what the server needs), is built with Node, and runs on distroless/nodejs24-debian13, from the same family as the binary’s image. As a reference, I also measured node:24-alpine, the one from Next’s official example, because it is the one most people copy and paste.

Requirement: all three images have to pass the same smoke test. A small image that does not work is not competing. It is decorating.

An environment variable in next.config.ts (BUILD_TARGET=node) turns standalone on and turns the adapter off, so both variants come from exactly the same code.

ImageUncompressedCompressed
The binary on distroless/cc-debian13162.6 MB65.3 MB
Node 24 standalone on distroless/nodejs24-debian13224.0 MB77.4 MB
Node 24 standalone on node:24-alpine229.1 MB82.2 MB

The binary is 27% smaller uncompressed and 16% smaller compressed than the best Node variant. But a number without a breakdown is an anecdote, so I split each image into layers:

LayerBinaryNode (distroless)
Base (glibc, libstdc++, OpenSSL…)~38 MB~38 MB
RuntimeBun: 88 MBNode: 123 MB
App (Next, sharp, code)36 MB, inside the binary63 MB (the standalone)
  • The runtime is the biggest difference. Node 24 weighs about 35 MB more than Bun, and there is nothing to optimize there: it is the price of admission for each of them.
  • Node’s standalone ships sharp twice, the glibc and musl variants. It is exactly the problem we fixed in the adapter: Next traces everything that is installed, even if that platform is never going to load it. It was comforting to discover it was not a bug in my adapter, but a family trait.
  • All of Next, versus only what is needed. In the standalone, node_modules/next brings everything the server might eventually load, picnic kitchen included.

And alpine? It weighs more than distroless, and that is going to hurt more than a few people. Alpine is a small operating system, but the node:24-alpine image ships Node with npm, yarn, and corepack. Alpine is light. The Node image did not get the memo.

To be fair

If you also strip the musl variant of sharp from the Node image, it drops to about 205 MB and the gap shrinks to about 42 MB. I did not apply that in the comparison because that is no longer the classic way of deploying Next, which is exactly what I wanted to measure. But the number has to be on the table.

Winning on size felt good for about three minutes. Then it hit me that image size is paid once per node, when the pod is scheduled there. CPU and RAM, on the other hand, are paid every second the pod is alive, multiplied by every replica. What I had done so far was the easy half. The other half was putting load on it, and load, as you are about to see, has an impressive talent for lying.


Act IV: The numbers lie too

I built load tests with k6 against both images. And before I show you a single number, a warning: I ran this first round on my Mac, with OrbStack. Yes, I know. I am telling you anyway, because the numbers already say interesting things and because what they do not say ended up being the most useful part of the whole act.

In a benchmark it is very easy to cheat without meaning to, so I set the rules before running anything:

  • Same resources: each container with 1 CPU and 1 GiB of memory, like a pod with limits. One at a time, never both in parallel, so they do not compete for the machine.
  • k6 inside the same Docker network, in its own container. If I run it from the host, I would also be measuring Docker’s network on macOS.
  • A fresh container per scenario, so none inherits the previous one’s cache or memory, and the images interleaved: if the machine heats up, it affects both equally.
  • 10 seconds of warmup discarded and 30 measured, with 32 virtual users sending requests without pause. That measures peak capacity and latency while the server is saturated.

Next does not do one thing, so there is no single performance number either. Each scenario stresses something different:

ScenarioWhat it stressesBinary req/sNode req/sBinary vs. Nodep95 binary / Node
staticPrerendered HTML3,5092,4951.4×11.7 / 20.3 ms
dynamicSSR with headers(), cookies(), and searchParams5862812.1×95 / 158 ms
cachePartial prerendering with "use cache"4352162.0×231 / 292 ms
apiDynamic route handler with after()3,9642,3121.7×10 / 40 ms
assetA file from .next/static20,1477,4892.7×3.2 / 6.7 ms
isr-newISR with a new page on every request111581.9×445 / 926 ms
imageOptimizing images with sharp6,2269,9490.6×9.0 / 4.9 ms

Zero errors in every scenario. Now the fun part: reading them with a magnifying glass.

Where there is rendering to do, the binary handles twice as much. In SSR, "use cache", and ISR, the difference is around 2×, with lower latency. That is the work that weighs the most in a real app; anybody can serve HTML that is already built.

Static files fly, but part of the credit is borrowed. 20 thousand requests per second against 7.5 thousand. Part of that advantage comes from the second law’s patch: the binary’s createReadStream reads the file from memory, while Node reads it from disk with a stream. It is not just Bun being fast. And in production a CDN is going to serve these files, so this number is more of a curiosity than an argument.

Images: the only scenario where Node wins. I asked each of them for the same image three times:

Binary: MISS → MISS → MISS   it optimizes with sharp on every request
Node:    MISS → HIT  → HIT    it serves it from .next/cache/images

The binary was not storing the optimized images anywhere. Next tried to write them to .next/cache, which in the binary lives inside $bunfs (read-only), failed, and carried on as if nothing had happened. Yes, another silent failure. By this point I should have gone looking for them first. And note: if it still reached 6 thousand req/s, it is because the test image is 64×64 and sharp processes it in about 5 ms. With a real photo, every request would pay the full optimization and that number would collapse.

Memory, the number that deceives the most. In the heavy scenarios, the binary used more RAM than Node: 281 against 241 MiB in SSR, and 537 against 356 MiB in ISR. Read that way, it looks like bad news. But in this test the virtual users send requests without pause, so the binary was handling twice as many requests in the same time, with twice as much work in flight. Comparing memory that way is like saying a bus uses more fuel than a car without counting the passengers.

What did leave me with a serious doubt was ISR. Without a disk, every new page that gets rendered stays in the pod’s memory. Does that stabilize at some point, or does it grow without a ceiling?

The fine print

This run is a first snapshot, not a conclusion. And its limitations, without makeup, are these:

  • macOS with OrbStack. The containers run in a Linux VM, sharing CPU with everything else happening on my machine. It is not a Kubernetes node.
  • A single run. I do not know how much these numbers vary from one execution to the next. Without repetitions, a 20% difference could be noise.
  • Saturation, not real load. It measures peak capacity, not how each one behaves at the same load. That is why the memory comparison is unfair.
  • A single container with 1 CPU. No replicas, no load balancer, no real network between pods.

A benchmark on a laptop is like a test drive in the dealership parking lot: it tells you whether the car starts, not how it behaves on the road.

This run did not tell me how fast the binary is. It told me what I had to measure next, and how carefully. But before any other test there was something more urgent. Look at the two worst results: the images that were optimized on every request, and the 537 MiB of ISR. They have the same cause: the cache lives inside the pod. And if with one pod it is already a memory problem, with ten it is the problem from the prologue: ten versions of the truth. It was time to keep the second promise.


Act V: Ten pods, ten truths

The idea was simple: every replica reads and writes the same cache, in Redis. But “simple” is exactly the word that usually comes right before the lost weeks, so before writing a line I had to understand how Next actually caches.

How Next caches (and why intuition is not enough)

The first surprise: in Next 16 there is not one cache, there are three.

CacheWhat it storesHow you replace it
IncrementalPrerendered pages, ISR, route handler responses, fetchA class in cacheHandler
"use cache"The result of functions and components with "use cache"A handler registered on a global symbol in Next, configurable with cacheHandlers
Optimized imagesThe variants sharp generatesThe same cacheHandler, but only if you turn on images.customCacheHandler

That last flag, easy to miss, confirmed that the images from the MISS → MISS → MISS could go to Redis.

The second surprise was how Next invalidates. My intuition was: if every replica reads the same Redis, why would one of them need to hear about what another invalidated? They are reading from the same place. The intuition was right, but it was missing a fact: in Next, invalidating is not deleting an entry.

  • By time, Next never deletes anything. Each entry stores when it was created, its revalidate, and its expire. On read it compares the age: if revalidate has passed, it serves it anyway and regenerates in the background; if expire has passed, it discards it and renders on the spot.
  • On demand (revalidateTag, revalidatePath), Next does not touch the entries either. It writes down “the tag posts was invalidated at 20:15:03” in a Map called tagsManifest, and when it reads an entry with that tag that is older than that time, it treats it as stale.

And that tagsManifest lives in each process’s memory. That is the trap of classic Next with several replicas: even if the pages were shared, the mark “this tag was invalidated” stays only on the pod that received the call. So nobody had to be notified of anything: those marks just had to go to Redis too.

flowchart TD
    A["Pod that invalidates"] -->|"writes the tag down, without deleting the page"| R["Redis"]
    R -->|"another replica reads the page and the mark"| B["Another replica"]
    B --> C["The page is older than the mark: it is stale"]

Before building, I looked for whether it already existed. Next has an official adapter for Bun, adapter-bun, with its own cache on SQLite. I studied it carefully and took one technique with me: it uses the adapter’s modifyConfig hook to inject the cacheHandler, and that same module registers the "use cache" handler when it is imported, so a single piece covers both caches. But its own documentation says it plainly: “No Redis. No cross-machine invalidation.” It solves a different problem, the single-server one. What I needed did not exist.

The rules of the game

The cache turns on at runtime, with an environment variable: if REDIS_URI exists, the binary modifies Next’s config before starting and points everything at Redis; if it does not, it behaves exactly as before. The same artifact serves every environment, and comparing with and without Redis does not require a rebuild. Every key carries the buildId, so that during a rolling update a new pod does not serve pages rendered with the previous version’s chunks. And if Redis goes down, the app keeps working without a cache: each operation returns a miss instead of failing, with a circuit breaker so no request sits waiting on a timeout.

But the decision that weighs the most is another one:

Decision

Replicate what Next does with .next/cache, instead of inventing a cache model of my own.

  • The TTL only cleans up. An entry is stored with a TTL only when it has a finite expire, and that TTL is the same expire: past that time, Next would discard it on read anyway, so Redis is only saving itself from storing garbage. Tag marks have no TTL.
  • Tag marks in Redis. In the incremental cache, the handler fetches each entry’s tag marks from Redis and loads them into Next’s tagsManifest, which decides with its own rules whether it is stale or expired. In "use cache", on the other hand, Next leaves that decision to the handler itself, so there I did replicate those rules (areTagsExpired and areTagsStale from Next) in a small file, tags.ts.
  • Prerendered pages are read from the binary with Next’s own code when Redis does not have them yet, without reimplementing their format.
  • No in-memory cache in front of Redis. Putting it in the pod’s memory or disk is exactly what we are trying to eliminate.

Why: every rule I reimplement is a rule that can drift from Next on the next upgrade.

Cost I accept: every read crosses the network to Redis. With a single replica that is probably slower than local disk. I am writing it down before measuring, so nobody reads it afterward as a defeat.

One obstacle was left before I could hook it up. Next loads the cacheHandler with an import() of a file path, and my handler is compiled inside the binary: it is not a file. The fix was a one-line shim that the adapter writes into $bunfs:

export default globalThis[
  Symbol.for("next-compiler.incremental-cache-handler")
];

The binary registers the class on that symbol at startup and points cacheHandler at the shim. Before building anything, I validated it with a minimal binary: an import() of the shim from a compiled executable returned the same class that was in the bundle. Ten lines of experiment before two hundred of implementation, again.

The ghost comes back

With two replicas of the binary pointing at the same Redis, the first test came out perfect: replica A rendered an ISR page and replica B served it from Redis, with the same timestamp. Shared cache, working.

Then I invalidated that page’s tag on A. And neither replica changed anything. Not even A, the one that had received the invalidation. That ruled out a distribution problem from the start: something was broken even with a single replica.

I inspected Redis by hand. The tag mark was written, in the exact format Next uses, and the page entry had that tag stored. The timestamps told the whole story:

page stored:      …234146 ms
tag invalidated:  …234188 ms   ← after

By Next’s rules, that page had to count as expired. But it was never rendered again. I added traces to my handlers, and that is where the strange part showed up:

RedisCache: get APP_PAGE …/isr/dbg-1 redis {
  lastModified: 1791765335590,
  marks: { "item-dbg-1": { stale: 1791765336147, expired: 1791765336147 } }
}

My handler was loading the correct mark into tagsManifest, later than the entry. And Next ignored it. There was only one explanation: Next was consulting a different tagsManifest.

flowchart TB
    subgraph two["Two copies of the same module"]
        S["Packaged server"] --> MA["tagsManifest A"]
        C["Chunks from $bunfs"] --> MB["tagsManifest B"]
    end
    H["The handler writes to A"] --> MA
    N["Next reads B"] --> MB

Remember the two copies from Act I? I told you they would come back. The binary has two copies of some Next modules: the one Bun packaged into the server, and the one the chunks load from $bunfs with my resolver. That time we were saved because Next stores the AsyncLocalStorage instances on globalThis. But tagsManifest does not: it is a module-level Map. Two copies of the module, two Maps. My handler wrote to one and Next read the other.

I looked through the chunks for who was requesting it, and there it was: require("next/dist/server/lib/incremental-cache/tags-manifest.external.js"). That .external in the name is the key: Next marks exactly this way the modules that have to exist only once, shared between the server and each route’s bundles. My resolver was breaking that guarantee. I made the full list: the app used 16 .external modules, and 6 of them kept state at module level, among them tag invalidations, the in-memory LRU cache, the manifests already loaded, and the state of instrumentation.ts.

This was not a Redis bug. It was an adapter bug from day one, and it also affected the binary without Redis. Nothing had exercised it yet. The suspicion I crossed off in Act I was never resolved: I got lucky with the first module I looked at.

Decision

A single instance of each .external module: the one packaged into the binary.

At build time, the adapter searches the staging for every require("next/dist/…external…") and generates a file with a deferred require() of each one, so Bun packages them once. When a chunk asks for an .external, the resolver hands it that instance instead of loading another copy from $bunfs.

Discarded alternative: patch only tagsManifest, and leave the other five waiting their turn to break in production.

Cost I accept: it depends on a naming convention in Next, not on a public API. If Next changes it, the duplication comes back in silence.

With that, invalidation worked across replicas, in both of Next’s modes:

A invalidates with { expire: 0 } → B renders on the spot → A receives the new version from Redis
B invalidates with "max"         → A serves the old one and regenerates in the background → B already sees the regenerated one

A latent bug does not show up when you look for it: it shows up when something new exercises it. The distributed cache did not bring this bug. It found it. And when you find it, the question is not “how do I fix this symptom?”, it is “what guarantee did I break, and where else did I break it?”.

Redis goes down, and nobody says a word

The rule “if Redis goes down, keep going without a cache” sounded great on paper. The first tests found three holes in it:

  • The circuit breaker opened on its own. The first request arrived before the initial connection finished, the command failed, and the breaker opened: five seconds without a cache on every startup. A connection that is still being established is not a failure. It is now a silent miss, and the circuit opens only on real failures.
  • The client gave up. Reading the Bun.redis types, I found that automatic reconnection gives up after 20 attempts and never tries again. On Kubernetes, if Redis is slow to come back, the pod would be left without a cache forever. The adapter now retries every five seconds, indefinitely.
  • The silence. When I stopped Redis, the app kept serving, as expected, but the log said nothing. The server knew it was disconnected (that is why it returned a miss), but it was not telling anyone.

An app that runs without a cache and does not say so is exactly the kind of silent failure that had been chasing me since sharp. By this point it no longer surprised me. It offended me. The log now records the outage, the recovery, and the case of starting with Redis unavailable.

With everything fixed, the test on the distroless image: two replicas of the binary and a Redis on the same network. The smoke test passed in full with Redis. A renders an ISR page and B serves it from Redis; B invalidates the tag and A renders the new version. And the one that closes the circle from Act IV: A optimizes an image (MISS) and B requests it (HIT). What used to be MISS → MISS → MISS is now optimized once and served by any replica.

Does Redis cost, or does it pay?

I told you I had written a prediction down: with a single replica, Redis is probably slower than local disk. Time to check it, with the same suite from Act IV and three variants interleaved in the same session: the binary without Redis, the binary with Redis, and classic Node. Redis counted as part of the system, not as a gift: its own container with its own limits, and a fresh empty one per scenario.

The numbers brought three clear improvements and one data point that did not add up:

  • Images, solved. The binary went from 6 thousand to 13 thousand requests per second, and it now beat Node, which was serving them from its disk.
  • ISR memory left the pod. In this run, the peak dropped from 552 to 367 MiB, and the pages moved to occupying 124 MiB in Redis. The system uses almost the same in total, but with eight replicas those 124 MiB are paid once, not eight times.
  • An improvement I was not looking for: "use cache". The p95 dropped from 232 to 89 ms. The explanation is in Next’s documentation: its in-memory cache discards entries as soon as they reach revalidate, because regenerating them in the background is usually wasted work if they are likely to be evicted before anyone reads them. A shared store does not have that problem, so the Redis handler keeps serving them while they regenerate behind the scenes. I did not design it for this. It fell out of replicating Next’s model faithfully.

The odd data point: while the binary was serving 13 thousand images per second from Redis, the Redis container was using 4% of CPU. Thirteen thousand reads per second at 4% CPU? It smelled like Next had some hidden in-memory layer and most requests never reached Redis. If that was true, the number was not measuring what I thought.

Instead of speculating, I counted. I reset Redis’s statistics, sent exactly one thousand requests for the same image, and asked Redis how many commands it had received:

1,000 image requests         → cmdstat_get: calls=1000, usec_per_call=7.63
1,000 requests for an ISR page → cmdstat_get: calls=1000 · cmdstat_mget: calls=1000

One GET per image; one GET and one MGET (the tag marks) per page. There was no hidden layer: Redis answers each read in about 8 microseconds, and that is why it barely uses any CPU. The number was good and it was real. When a number looks too good, do not celebrate it and do not throw it out: measure the mechanism that produces it. Two minutes with INFO commandstats are worth more than an afternoon of theories.

And the prediction? Wrong, and I am glad. Redis did not make the binary measurably slower, not even with a single replica. If I had not written it down beforehand, I would now be saying “I knew Redis was going to turn out fine”.

Apples to apples

That run had an unfairness: the binary had Redis and Node did not. So I gave Node the same architecture, with a twin Redis handler: the same keys, the same format byte for byte, and the same tag marks. If one handler stored things better than the other, I would be measuring who wrote the better handler, not Bun against Node.

ScenarioBinary + Redis req/sNode + Redis req/sBinary vs. Nodep95 binary / Node
static3,7222,1001.8×11.7 / 29.4 ms
dynamic6592772.4×75 / 158 ms
cache5062552.0×90 / 170 ms
api3,5931,9641.8×11 / 44 ms
asset19,3827,7622.5×3.3 / 6.5 ms
isr-new110601.8×459 / 1,160 ms
image13,7468,0101.7×4.0 / 6.1 ms

With the same cache, the advantage holds: between 1.7 and 2.5 times more, with lower latency in all seven scenarios. It did not come from Redis. It came from the runtime and the packaging. And it shows even more in the tail: in ISR, Node’s p95 goes past a second.

But with a single pod, Redis is a bet with visible costs and invisible benefits. Its real value, that every replica sees the same thing, only exists when there is more than one. And that is not measured on my Mac. It was time to get on the road.


Act VI: The open road

Kubernetes in one command

To bring the environment up I used Tilt, for a reason that has nothing to do with performance: so that anyone can reproduce it. Tilt is, more or less, the docker-compose of Kubernetes: a Tiltfile declares which images to build and which manifests to apply, and tilt up brings everything up.

The environment has three pieces: next-bun (8 replicas of the binary with REDIS_URI), next-node (8 replicas of Node with the twin handler), and a shared redis. The cluster is a single node with 10 CPUs, so I gave half a CPU to each replica and tested one variant at a time, with the other scaled to zero.

flowchart LR
    k6["k6 Job, inside the cluster"] --> svc["Service"]
    svc --> pods["8 replicas, 0.5 CPU each"]
    pods --> redis["Shared Redis"]

With 8 replicas at 1 CPU, plus k6 and Redis, I would be measuring the node’s saturation and not the app’s capacity.

On deploy, something showed up that I was not expecting to measure yet: several Node pods failed their first readiness probes because they took longer to start, and Kubernetes had to wait for them. The binary’s were ready immediately. When an autoscaler creates pods because traffic arrived, every second of startup is a second with less capacity than you need. And the Node manifest had to repeat something the binary solved in Act II: HOSTNAME=0.0.0.0, because Next’s standalone server listens on that variable and Kubernetes fills it with the pod name.

The port-forward trap

A detail worth knowing before you measure anything on Kubernetes: kubectl port-forward svc/next-bun does not load-balance. Even if you point it at the Service, kubectl picks a single pod when it opens the tunnel and sends everything there. A load test through port-forward measures one pod, not eight, and it tells you so with complete calm.

For the load to spread, the generator has to be inside the cluster and hit the Service name. That is why k6 runs as a Kubernetes Job, with the script mounted from a ConfigMap that the Tiltfile itself creates. I verified it by counting which pod served each request: 40 requests, spread across the 8 pods.

And before the benchmark, the test that justifies all of Act V: 8 requests to the same ISR page, through the Service and therefore on different pods, return the same timestamp. I invalidate the tag, and all 8 return the new one. On both variants. Eight pods, a single truth.

Eight replicas, same CPU

The test the whole project had been putting off: both variants with 8 replicas, the same cache in Redis, k6 inside the cluster, and two repetitions of the full suite.

ScenarioBinary req/sNode req/sBinary vs. Nodep95 binary / NodeTotal CPU binary / Node
static9,585 (±4%)3,730 (±2%)2.6×23.5 / 57.9 ms361 / 360%
dynamic1,581 (±3%)665 (±2%)2.4×87.6 / 197.1 ms361 / 358%
cache1,398 (±1%)583 (±2%)2.4×94.2 / 208.1 ms361 / 359%
api9,196 (±1%)3,910 (±1%)2.4×29.7 / 58.7 ms367 / 366%
asset23,024 (±2%)21,004 (±2%)1.1×7.2 / 7.6 ms201 / 357%
isr-new339 (±5%)207 (±2%)1.6×326 / 452 ms335 / 358%
image34,257 (±1%)19,854 (±0%)1.7×4.9 / 8.8 ms328 / 354%

Zero errors across the 28 runs, and variation between repetitions from 0 to 5%. For the first time in the whole project, the noise band is narrow and it is measured, not estimated.

But the column that matters is the last one. In almost every scenario, both variants use about 360% of CPU: eight pods at ~90% of their half-CPU limit. Both are saturating exactly the same budget. That makes this the cleanest comparison in the whole project: the throughput difference is, directly, efficiency per CPU.

On the same CPU, the binary serves between 2.4 and 2.6 times more requests in everything that renders or runs server code. The way the person paying the bill would put it: to serve the same load, Node would need about 2.4 times more CPU. More replicas, more nodes, more money. And latency comes along: Node’s p95 is more than double in almost every scenario.

The two exceptions teach something too:

  • asset ties on throughput, but not on efficiency. The binary served 23 thousand requests per second using 201% of CPU: it was not saturated, the bottleneck was the load generator. Node reached 21 thousand while saturating its own. They serve the same, but the binary does it with a little over half the CPU.
  • ISR is the smallest advantage (1.6×), and that makes sense: it is the scenario that writes to Redis the most, and there part of the time is network, not runtime. In 40 seconds, the binary left 25,004 pages in Redis and Node left 15,963.

And memory? I measured each process’s private memory, not counting the operating system’s file cache. Both variants start the same: 48 against 47 MiB per pod. Under load, the binary uses more, between 1.2 and 1.9 times more depending on the scenario, while serving between 1.6 and 2.6 times more requests. Per request it spends less; in absolute terms, it spends more. The fair comparison, both at the same fixed request rate, is still pending.

The fine print, so nobody quotes this without it: it is a single node, with the replicas, Redis, and k6 on the same VM; the generator capped the lighter scenarios, as asset showed; and it is a test app, with no database and no calls to other services. In a real app, part of every request is waiting on the network, and there the runtime weighs less.

But the answer to the question from the prologue is on the table. One binary and one cache: both promises kept, on eight replicas that see the same truth and serve more than twice as much on the same CPU.


Epilogue: what the benchmarks do not tell you

Before you run off to replace your Node images, I owe you the honest part. This design rests on things nobody promised me: internal paths in Next, the Turbopack runtime, the .external convention, the tagsManifest, and the behavior of $bunfs in Bun, which is not documented as a contract. On top of that, it patches require and fs.createReadStream for the whole process. What protects me is three things: pinned versions, the smoke test on every upgrade, and the minimal experiments, which let me revalidate each law of the system in minutes.

That is why there are cases where I would not use this:

  • If nobody on the team is going to maintain the adapter on every Next upgrade. output: "standalone" with Node is boring, and in production “boring” is a compliment.
  • If the app depends on many native packages, or on reading files from disk at runtime.
  • If the cost does not hurt: few pods, no aggressive autoscaling, no pressure on the bill. Optimizing something that does not hurt is not engineering. It is a hobby. It is fine to have one, but do not put it on the roadmap.

And there is work left. The memory test at the same request rate is still missing, the only truly fair RAM comparison. Build pages are immutable and identical on every replica, so they deserve an in-memory cache that breaks no rule. I want to extract sharp during the image build, so a pod with readOnlyRootFilesystem does not need a writable directory. And the list of native packages has to stop being a handwritten ["sharp"].

What I learned (and what I want you to take with you)

I learned each of these the hard way. The idea is that you take them without the bruises.

  • I learned that the stubs in Act III were acceptable not because they would never break, but because, when they broke, the binary merely weighed more, or it failed with a clear message. Take this: when you accept a coupling, design how it will fail, not only how it will work.
  • I learned that sharp, the static files, the image cache, and Redis could fail without saying a word, and that in every case the first step was making the error visible. Take this: before you fix a silent failure, make it speak.
  • I learned that a fifteen-line binary answered in two minutes what reading the Next server would have taken me hours. Take this: when a large system fails, isolate it with the smallest experiment you can build.
  • I learned that the cache works because it follows Next’s rules, and that is why it even improved something I was not looking for. Take this: before you invent a model of your own, replicate the platform’s.
  • I learned that .external is not an API but a promise: “there is only one copy of this”. Breaking it did not produce errors. It produced different behavior, in silence, much later. Take this: respect the implicit guarantees, even when nobody wrote them in the docs.
  • I learned that my prediction about Redis was wrong, and that a number that looked too good was explained by counting commands. Take this: write the prediction down before you measure, and when a number looks too good, measure the mechanism that produces it.

Now it’s your turn

The code is at github.com/luiscib3r/next-compiler, with the test app, the smoke test, the benchmarks, and the Kubernetes environment:

bun install
bun run build            # next build + the adapter → build/server
PORT=8080 ./build/server
bun run smoke            # walks every page of the test app

With docker compose up you have it with Redis, and with tilt up, on Kubernetes with eight replicas. Break it, change the Next version, put your app on it. And if you find a law of the $bunfs world that I missed, tell me.

For my part, I am still at it. The last time I compiled it, the binary said ✓ Ready, I ran the curl, and it returned a 200.