Bytecode caching
Skipping JS recompilation on cold pods — bytecode baked into the compiled executable by default, or a V8 compile cache in the image on the legacy Node shape.
Scale-to-zero means cold starts. The expensive part of a Next.js cold start is the JS engine compiling JavaScript from source. knext does that work at build time and ships the result inside your application image. On the default target the whole server bundle is precompiled to bytecode inside the compiled executable; on the legacy Node standalone shape it is a V8 compile cache baked into the image. This page covers the Node mechanism.
There is nothing to configure and nothing to enable. The cache is built when your image is built, so it is present from the very first pod, on any cluster.
How it works (Node)
During the image build, knext boots your server exactly once — the same server.js that runs in
production, so if you have an instrumentation.ts with a register() hook, it runs during this
build-time boot too — and lets V8 serialize its compiled bytecode to a cache directory inside the
image. At runtime the server reads that cache instead of parsing and compiling your application's
JavaScript again.
That one boot needs to prove the server actually works before the build can trust the cache it produced:
- It sends a request to your configured health check path (
healthCheckPathinkn-next.config.ts, default/api/health) and requires a 2xx response within 30 seconds. A custom health route works automatically throughkn-next deployandkn-next preview— the two commands that actually rundocker build— which thread it through as a--build-argautomatically, nothing extra to configure. A manualdocker buildof a staged Dockerfile (this page's Node image, or the vinext-on-Node image — see vinext on Node) needs--build-arg KNEXT_HEALTH_CHECK_PATH=<your path>passed by hand. Either way, if your app has no route at the configured path, this step, and the image build with it, fails. - It checks the cache that came out the other side against a minimum size. A cache that is present but implausibly small looks exactly like a healthy one — same configuration, same startup path, no error — while doing nothing at runtime, so the build refuses to ship it.
- It runs as the same non-root user the container runs as in production. The cache directory is keyed by the user that wrote it, so if your own image customisations (a custom base image, an added build stage) end up running this step as a different user — root, most commonly — the cache is written but the runtime, back on its normal user, never reads it: present on disk, silently unused, no error at build time or at runtime.
Because the cache lives in the image layer:
- It works on a stock Kubernetes or Knative install. No storage class, no volume, no cluster storage configuration of any kind.
- It is there on the first cold pod, not merely on later ones. A pod that wakes from zero reads a cache that was already populated before the image shipped.
- It never goes stale. The cache is versioned with the image it lives in, so a new build always carries a cache that matches the code it was built from.
- It does not constrain how wide you scale. Every pod carries its own copy, so there is no shared resource to contend over and no limit on how many nodes your app spreads across.
The trade-off is image size: the cache adds a layer of serialized bytecode to every image.
The build fails if the cache comes out empty. A compile cache that is silently never populated looks exactly like one that works — same configuration, same startup path, no error — while delivering nothing. The build checks that the cache was actually written rather than trusting that it was.
Diagnosing a failed bake (Node)
If the image build fails at this step, the log names which of the three checks above tripped:
- "a warm path did not answer 2xx" — the health-check request failed or timed out. Most often
this means
healthCheckPathinkn-next.config.tspoints at a route your app does not have, or the route depends on something (a database, an external service) that is not reachable during the image build — the health route your production pods answer at runtime and the one this build-time boot warms must both work without that dependency, or must fail open when it is absent. - "below the floor" — the cache came out smaller than expected. This usually means the boot
above failed silently in some other way (check the rest of the build log for warnings from your
own
instrumentation.ts), or the server exited before the warm-up request completed. - No explicit error, but slower cold starts than expected in production — check that nothing in your own image customisation changes which user runs the build steps around this one; the cache is written and read by the same fixed non-root user, and a mismatch there produces no build error at all, only a cache the runtime silently never reads.
This is independent of your data cache. Bytecode caching governs how fast the server boots;
the cache provider (redis, and friends) governs where ISR and data are cached. The two are
unrelated and neither one turns the other on.
What to expect
Skipping compilation removes real work from every cold start, and the effect is measurable on the server-boot portion of a cold start. How much of your end-to-end cold start that represents depends heavily on your cluster: on a small cluster where pod scheduling and image pull dominate, compilation is a smaller slice of the total than on a fast, warm-image cluster.
Treat it as a real but environment-dependent improvement, not a guaranteed number. Measure your own cold start if it matters to your service-level objectives.
Verified live, not just configured
knext's compatibility results for a runtime count only on runs where bytecode caching is shown to be working at runtime. Configuring it isn't enough. Every deployment in a compatibility run records evidence, and a run where any deployment lacks it doesn't count toward the published credential.
| Runtime | What counts as live |
|---|---|
| Node | Each test app is prepared with the same cache-bake step and server entry that knext ships in the Node image, but outside a container. The bake step builds the compile cache ahead of time, and the app then boots through knext's server entry. The bake warms a real page route of the app (its root when it has one), never a static file. Some test apps answer every route with a 404 or 500 by design; a rendered error page still loads the server runtime, so any complete HTTP response counts for the warm, while a dropped or reset connection does not. The bake step must succeed, and Node's diagnostics must report that the server process accepted cached code at startup: at least 100 modules, and at least half of everything it looked up. Only code the server loads by the time it is ready is checked. Code that first loads on a later request is not. A cache directory that merely exists on disk proves nothing, because Node writes one on every exit, including a cold boot. |
| Bun | The server that boots is the compiled executable, and its bytecode was verified when it was built. A deployment that boots the uncompiled server script doesn't count. |
You can run the same check against your own image on Node. Start the container in the background
with NODE_DEBUG_NATIVE=COMPILE_CACHE, give it a few seconds to boot, then count the lines that say
was accepted in its logs:
docker run -d --name cache-check -e NODE_DEBUG_NATIVE=COMPILE_CACHE <your-image>
sleep 5
docker logs cache-check 2>&1 | grep -c 'was accepted'
docker rm -f cache-checkA healthy image reports hundreds of accepted entries. Zero means the cache is not being read. The most common cause is a bake that ran as a different user than the one the server runs as, because Node stores the cache per user.
If the cache directory is unavailable
Bytecode caching is an optimisation, never a dependency: if the cache directory cannot be used, your app still boots and serves normally — it simply pays full compile cost on each cold start.
That covers every way the directory can go wrong: it was never created, it points at a volume that did not mount, it is read-only or otherwise unwritable, it is a file rather than a directory, or it holds a half-written entry left behind by a pod that was killed mid-write. None of these fail a boot, and none of them need a fallback setting from you.
The trade-off is that a broken cache directory is otherwise silent — the app looks healthy and is just slower forever. So knext logs a single warning shortly after startup when it detects that the cache directory it was given was refused:
NODE_COMPILE_CACHE=/mnt/cache/bytecode was refused by the runtime (unwritable, unmounted, or not a
directory); the server is serving normally but WITHOUT bytecode reuse, so every cold start pays full
compile cost.If you see it, check the path you pointed the cache at — most often a volume that is mounted read-only or is missing entirely.
The warning is deliberately narrow, so these cases stay quiet:
- You did not override the cache location. The default lives inside the image, where it is always readable.
- You turned bytecode caching off with Node's
NODE_DISABLE_COMPILE_CACHE. That is a deliberate choice, not a broken volume, so it is reported as disabled rather than warned about — and note that Node treats the variable's presence as "off", includingNODE_DISABLE_COMPILE_CACHE=0. - You run the Bun runtime. Bun does not implement this cache at all (it has its own mechanism, below), so there is nothing to report either way.
Node vs Bun
Both runtimes get a compile cache, built at image build time, via different mechanisms:
| Runtime | Bytecode mechanism |
|---|---|
| Node (default) | V8 compile cache, populated during the image build and shipped in the image. Read by every pod from its first boot. The vinext build on Node bakes it the same way, and fails the image build if the bake produces no usable cache (see vinext on Node). |
Bun (runtime: 'bun') | Whole-bundle bytecode in a compiled executable: kn-next build compiles the server into a Bun single executable with bytecode — the vinext build by default, and next build's standalone server on the standalone target — and fails the build if the bytecode is missing. See Bun runtime. |
A Bun bytecode-built image is Bun-only — booting it with Node exits 1 with a FATAL message
naming the fix rather than crash-looping silently. Details and costs are on the
Bun runtime page.
If your config still has a bytecodeCache block
Removed. The opt-in volume-backed bytecode cache is gone. The compile cache is baked into your
image by default, so there is nothing to enable — delete the bytecodeCache block from your config
and the cache.enableBytecodeCache / cache.bytecodeCacheSize fields from any NextApp you apply
by hand.
Earlier versions of knext kept the Node compile cache on a persistent volume so it survived pod death. That approach was superseded and has now been removed, because it had two limitations the build-time cache does not:
- Most Kubernetes and Knative installs reject the volume outright unless a cluster administrator turns on optional persistent-volume support, so the app simply fails to start.
- Where it did work, the volume attaches to one node at a time, so a pod scheduled onto a second node sat Pending instead of serving traffic — capping how wide the app could scale.
Leaving the old settings in place makes your deploy fail, in two different places depending on where they are:
- In
kn-next.config.ts—kn-nextrejects abytecodeCacheblock up front, and tells you what replaced it. It is refused rather than ignored on purpose: a setting that is quietly dropped looks exactly like a setting that is working. - In a
NextAppyou apply by hand — the fields no longer exist on the resource, andkn-nextapplies with strict validation, so the cluster rejects the unknown field.
Remove them:
export default {
cache: { provider: 'redis', url: process.env.REDIS_URL },
// bytecodeCache: { ... } <- delete this line entirely
}Removing it does not disable bytecode caching — the cache in your image is always active.
Cold starts & image caching
Keep your application image resident on every node so a pod waking from zero never waits on an image pull — and the node cost of doing it.
Bun & the compiled executable
knext's default build compiles your app into a single executable — bundled, minified, and bytecode-precompiled, with the Bun runtime baked in.