Optimizing TanStack Start for Cloud Run

Back to Blog Listing

Optimizing TanStack Start for Cloud Run

TanStack Start runs on Nitro, and Nitro's default Node output can leave server code split across many files. On Google Cloud Run, that file fan-out can make cold starts painfully slow. Bundling the server and pre-warming Node's compile cache can dramatically improve first render time.

Ben HoustonJune 20, 20264 min read

I have been using TanStack Start for this website, and I continue to like the stack. I wrote earlier about why TanStack Start appealed to me, especially its explicit routing model, type safety, and clean server function story.

I also moved this site to Google Cloud Run because I like the deployment model: container in, managed HTTPS and autoscaling out, and scale-to-zero when traffic is quiet.

Those two choices mostly fit together well. But there is one deployment detail worth knowing: TanStack Start's server runtime is Nitro, and Nitro's default Node server output is not optimized for Cloud Run cold starts.

The Hidden Cost#

Cloud Run containers run in a sandboxed environment. Filesystem access still works normally from the application point of view, but it is not the same as reading from a local NVMe drive on your workstation. Each open, read, and stat during startup has more overhead.

That matters for Node.js apps because server startup often means synchronously loading a large dependency graph. If the server is split across many generated chunks and traced node_modules files, startup becomes a long sequence of small filesystem reads.

On a local machine, that file fan-out may be mostly invisible. On Cloud Run, it can dominate the cold start path.

Nitro's Default Output#

Nitro is a powerful runtime layer, and its default Node server preset is designed to produce a portable .output directory. That portability is useful, but the default output does not necessarily bundle the server into one file.

In practice, the generated server entry can be a small stub that imports additional server chunks and traced dependencies from .output/server/node_modules. That is a reasonable default for compatibility, but it is not ideal when the deployment platform makes many small file accesses expensive.

For Cloud Run, the first optimization is to reduce the number of server-side files Node has to touch during startup.

Bundle the Server#

For a TanStack Start app using Nitro, the key option is:

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

In this project, I also selectively inline pure JavaScript dependencies through Nitro's externals.inline option. I do not recommend blindly bundling everything. Native packages such as sharp, and instrumentation-heavy packages such as Sentry/OpenTelemetry, should be treated carefully and tested in the final container.

The goal is not ideological purity. The goal is to make the startup path touch far fewer files.

Then Pre-Warm Node's Compile Cache#

Once the server output is bundled, Node's compile cache becomes more attractive. I wrote about the small CLI I built for this in node-prewarm: CLI for Node 25's Compile Cache.

The basic Docker pattern is:

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

node-prewarm starts the built server during the Docker build, waits until it is listening, then shuts it down cleanly. That gives Node a chance to populate the compile cache before the deployed container receives its first real request.

This matters most after bundling. If your server still touches thousands of files during startup, the compile cache can add more filesystem reads and make things worse in a sandboxed environment. Bundle first, prewarm second.

The Results#

On this blog system, the difference on Cloud Run was not subtle:

  1. Unbundled server files: first render was 12.4s.
  2. Bundled server output: first render dropped to 1.64s.
  3. Bundled server output plus Docker build node-prewarm: first render dropped again to 1.4s.

The main win came from bundling the server files. Pre-warming the compile cache provided a smaller but still useful improvement after the file fan-out problem was fixed.

That order matters. If you only add compile cache to an unbundled app, you may be optimizing the CPU side while making the Cloud Run filesystem side worse.

Practical Guidance#

For TanStack Start on Cloud Run, my current checklist is:

  1. Use Nitro's inlineDynamicImports: true for the Node server build.
  2. Inline pure JavaScript dependencies selectively if it reduces runtime file fan-out.
  3. Keep native and instrumentation-sensitive packages external unless tested.
  4. Add node-prewarm during the Docker build after the production server is built.
  5. Measure first render time on Cloud Run, not just local startup time.

Local benchmarks can be misleading here. On my machine, many small files are cheap to read. On Cloud Run, reducing file count was the difference between a painful cold start and a reasonable one.

TanStack Start is still a great fit for the kind of React applications I like to build. The main lesson is that the deployment target matters. If you are deploying TanStack Start to Cloud Run, make the Nitro server output Cloud Run-friendly before judging the framework's cold start behavior.