node-prewarm: CLI for Node 25's Compile Cache

Back to Blog Listing

node-prewarm: CLI for Node 25's Compile Cache

New in Node 25 is the node compile cache, which can deliver a 20% to 30% boost in server start-up time. I built `node-prewarm` to make that practical on real projects with minimal changes.

Ben HoustonMay 15, 20265 min read

When Node.js made the module compile cache stable in Node 25.4.0, I wanted to use it. A build-time step that measurably cuts startup time with no changes to application code is worth adopting.

In earlier experiments, I had seen results in the 20% to 30% range. On this website, the final measured improvement landed at about 20%, worthwhile for such a low-impact change.

This matters even more on scale-to-zero platforms such as Google Cloud Run, which is what I use for this blog. Faster startup times reduce the penalty of cold starts, which makes it more practical to let the service scale all the way down to zero when nobody is using it instead of paying to keep instances warm all the time.

The problem is that the raw feature is only a mechanism. You still need a practical way to start your app during a build, wait until it is actually listening, and then shut it down cleanly so the compile cache is populated before the real deployment starts.

I wanted something I could drop into different Node projects without invasive application changes, so I built node-prewarm.

Why This Is Useful#

Node's compile cache stores compiled module output on disk. On later starts, Node can reuse that work instead of recompiling everything again.

In practice, that means you can often improve startup responsiveness with only two ingredients:

  • Set NODE_COMPILE_CACHE to a writable directory.
  • Start the app once during your Docker build so Node has a chance to fill that cache.

The orchestration is the tricky part. You need to know when the app is actually ready, and you need to stop it gracefully afterward. That is the gap node-prewarm fills.

What node-prewarm Does#

node-prewarm runs your normal Node startup command, waits until a TCP port accepts connections, and then sends SIGTERM so the process can exit cleanly.

That makes it work well for build-time cache generation:

node-prewarm "node server.js" --port 8080

If you just want to measure your server's start-up time and not require NODE_COMPILE_CACHE to be defined, you can use:

node-prewarm "node server.js" --port 8080 --dry-run

In --dry-run mode, it still times startup until the app is listening.

Docker Integration#

This was the main design goal: low-impact integration. I did not want to add custom application code just to make compile cache practical.

Here is the basic Docker pattern:

FROM node:26 AS build
WORKDIR /app

COPY package*.json ./
# assuming node-prewarm installed via package.json
RUN npm install

COPY . .
RUN npm run build

ENV NODE_COMPILE_CACHE=/app/.node_compile_cache
RUN npx node-prewarm "node server.js" --port 8080

CMD ["node", "server.js"]

If your image already sets PORT, node-prewarm will respect it. The important thing is that the prewarm step happens during the image build, so the first real container start can benefit from the populated cache.

Benchmark Results on This Website#

To test this on a real project, I used this personal website as the benchmark target.

I measured three things:

  1. A baseline with compile cache explicitly disabled.
  2. A prewarm pass that generated the cache.
  3. A second round of timed starts using the generated cache.

Across repeated runs on this website's production build, the median time until the server listened dropped from 290ms without compile cache to 238ms with a prewarmed compile cache. That is about a 20% improvement in startup responsiveness.

The prewarm pass itself took 316ms during the build and created 3.5MB of data.

For me, that is the sweet spot. A build-time step of about a third of a second bought a noticeable startup win.

A Cloud Run Caveat#

There is an important catch for serverless containers, especially Google Cloud Run. Harisam Sharma pointed out that compile cache is only an obvious win if the startup path does not have to touch a huge number of files.

On a local workstation, filesystem calls are extremely cheap, so loading cached bytecode from disk usually saves more CPU than it costs in I/O. On Cloud Run, the filesystem sits behind a sandboxed runtime, and each synchronous open, read, and stat during startup is more expensive. If an unbundled server imports thousands of small files from node_modules, enabling compile cache can add enough extra cache reads that the I/O cost overwhelms the saved compilation work.

The practical lesson is to reduce the server-side file count first. If you use Nitro, that usually means favoring a single large server output file for Cloud Run-style deployments:

nitroV2Plugin({
  preset: 'node-server',
  inlineDynamicImports: true,
});

After making that change for this blog system, the Cloud Run first-render time dropped to 1.64s. Then, after adding a node-prewarm pass during the Docker build so the compile cache was already populated, the first render dropped again to 1.4s.

The contrast with the unbundled case was dramatic: without bundling the server files, the initial first render on Cloud Run was 12.4s although that dropped to 9.81s with pre-warm. In other words, the big win was eliminating the server-side file fan-out first; the compile cache then provided an additional improvement on top of the bundled output.

For this site, I also found that selective inlining is safer than trying to bundle everything. Native packages such as sharp, and instrumentation-heavy packages such as Sentry/OpenTelemetry, are better left external unless you have tested them carefully. But the broad direction is clear: on Cloud Run, a smaller module graph at startup is often more important than a smaller individual file.

So my current guidance is:

  1. Bundle the server into as few files as your framework safely allows.
  2. Prewarm the compile cache after that bundled build exists.
  3. Measure both uncached and cached startup in the same environment where you deploy.

Conclusion#

Node's stable compile cache delivers a real startup improvement, but the raw feature requires orchestration to use in practice: start the app, wait until it is listening, then shut it down cleanly. node-prewarm handles that so you do not have to wire it up per project.

If you run Node 25+ services and care about startup time, drop it into your Docker build, benchmark with --dry-run, and measure the difference. On scale-to-zero deployments like Cloud Run, where cold-start latency determines whether you can run at zero instances, every improvement compounds.