Persistent Metro: 1,750x faster than spawning expo export:embed per request

One-shot bundlers pay cold-start every invocation. Persistent Metro dev server inside each preview container takes file writes to 4ms and warm bundles to 100ms. The pattern generalizes — any incremental build tool with a watch mode wants to be a daemon, not a script.

AppX team ·

Persistent Metro: 1,750x faster than spawning expo export:embed per request

AppX is an AI mobile app builder. The user types a sentence, our pipeline writes React Native code, and their phone — already holding a QR code in Expo Go — shows the change. The product lives or dies on the latency between "AI emitted a diff" and "phone re-rendered." Anything north of a second feels broken. Anything north of five seconds and the user has put their phone down.

The bundler is in the middle of that path. React Native ships through Metro: build a module graph, transform the JS, hand Expo Go a bundle. The naive way to wire "AI changed a file, give me a new bundle" is to spawn expo export:embed on each change. It works. It is also pathologically slow. We measured ~7 seconds per platform, per request, on a warm box. With two platforms that is 14 seconds of dead air on every AI edit.

The fix is not a faster bundler. The fix is to stop spawning the bundler.

The naive shape (and why it kills your UX)

expo export:embed is a one-shot bundle generator. Each invocation does the same expensive cold-start work:

  1. JIT-compile the bundler itself.
  2. Walk node_modules, parse package manifests, resolve every entry point.
  3. Build the module graph from index.js outward — typically 2,800+ modules in an Expo SDK 54 app.
  4. Run every transformer (Babel, the asset pipeline, the symbolicator) on every module.
  5. Emit the bundle. Exit.

Look at that list. Steps 1 through 4 are identical to the previous request. The user changed one screen. We re-walked node_modules, re-parsed 2,800 files, re-warmed Babel — all to bundle a one-line edit. You are paying for amortizable work as if it were unique work.

  fresh-process-per-request           persistent daemon
  =========================           =================

  AI writes file -> 4ms               AI writes file -> 4ms
                                              |
  spawn expo export:embed                     v   (watcher fires)
   |                                  Metro incremental rebuild
   v                                          |
  load bundler                                v
  parse node_modules                  next /bundle hit: 100ms
  build module graph                  (2,800 modules already in RAM)
  run transformers
  emit bundle
  exit
   |
   v
  total: ~7,000ms per platform        total: ~115ms

A round trip on a warm laptop is ~7 seconds per platform. For an AI agent that edits eight screens in close succession, that is most of a minute spent re-doing identical work. The product feels like a code-gen demo with a 30-second spinner.

The persistent shape

Metro already has the right shape — it just isn't the shape export:embed uses. npx expo start runs Metro as a long-lived dev server with file watching enabled. The first request pays the full cold-start cost. Every subsequent request pays only the incremental rebuild.

In each preview container we spawn Metro once, on container startup, pointed at the directory where AI-generated source lives:

npx expo start \
  --no-dev \
  --minify \
  --max-workers 2 \
  --host lan

Then we do nothing. The backend pushes new files into the watched directory with a plain fs.writeFile. Metro's filesystem watcher sees the write, walks the dependency graph from the changed module, invalidates the affected nodes, and stages an incremental rebuild. The next GET /index.bundle from Expo Go serves the new code.

The file-write handler is anticlimactic:

// inside the preview container
async function applyEdit(path: string, contents: string) {
  await fs.writeFile(path, contents, "utf8");
  // done. Metro's watcher will pick it up.
  // do NOT trigger a rebuild here — the daemon owns its own lifecycle.
}

No webhook to the bundler. No "please rebuild" RPC. The build tool already has a file watcher; using anything else is fighting the tool. The bundler is a daemon now, not a script.

The numbers

Same Expo SDK 54 app, same box, two execution shapes:

  • File write itself: 4ms (was 7,000ms when "write" meant "spawn the bundler").
  • Warm bundle fetch after a one-file edit: ~100ms.
  • End-to-end "AI emits file -> phone has new bundle": ~115ms.
  • Cold first request after container boot: ~5.6s web, ~7s iOS. Paid once per container, amortized over hundreds of edits.
  • Module graph held in RAM across requests: 2,800+ modules.

The cumulative speedup on hot-path requests is roughly 1,750x. That is not a tuning win. That is a category change. A 115ms loop feels live. A 7,000ms loop is a chore. The user behavior on the other end of the wire flips — they start chat-editing in tight cycles instead of batching changes to avoid the wait.

Why this matters for AI agent loops specifically

A human developer touches a file every minute or two. The cold-start tax on a one-shot bundler is annoying but survivable.

An AI agent making edits via tool calls is a different traffic shape. Our edit engine can emit four file writes in under two seconds while the model reasons through a refactor. Multiply 7 seconds of cold-start tax by every tool call and the agent's reasoning latency disappears under bundler latency. Worse, the user sees a stutter on every step.

A persistent daemon flattens this. The first request after container claim pays the full cold-start. Every subsequent tool call in the same session — and there are often hundreds before the user disconnects — pays only the diff cost. AI agents amortize cold-starts better than humans because they generate more, faster.

Tradeoffs and failure modes

Persistent build daemons are not free.

Memory. Metro holds its module graph, transformer caches, and worker pool in RAM. We see ~50-80MB baseline per container on top of the Expo runtime itself. Fine for long-lived previews. Fatal if you tried to spin one up per HTTP request — you would burn the cold-start budget on container provisioning instead.

Lifecycle coupling. Containers must outlive a single request. The whole speedup depends on the daemon and its in-memory graph surviving across edits. If your platform tears down workers between requests (any "serverless function" model), this pattern does not compose. You need a stateful per-user sandbox.

Wedging. Metro can hang on a bad import — circular dependency, missing module, a transformer crash on malformed JSX. The hot-reload channel can desync from the file watcher. Some filesystems drop watcher events under load (bind-mounts, network filesystems, certain Docker storage drivers). We learned to: (a) health-check Metro with a periodic bundle fetch, (b) restart the daemon on the first sign of wedge rather than trying to recover, (c) keep the watcher rooted on a local-disk path inside the container, never a bind-mount from a network volume.

The pattern only works if the daemon is treated as crash-only software — kill it, restart it, the next bundle request pays one cold-start and the loop is healthy again.

The generalization beyond Metro

Metro is one instance of a broader pattern. Every incremental build tool worth using already has a watch mode:

  • tsc --watch holds the type graph and project references across edits. Per-edit incremental check is ~100ms; spawning tsc fresh on a real codebase is 10-30 seconds.
  • Vite holds its module graph, transformer pipeline, and HMR socket open. HMR is sub-100ms; a fresh vite build is many seconds.
  • esbuild --watch keeps the dependency graph in memory. Incremental rebuild is single-digit ms.
  • webpack --watch does the same, slower, but the gap to fresh-process invocation is just as wide.

The shape is identical: long-running daemon, file watcher, in-memory graph, incremental rebuild on change. The speedup over fresh-process-per-request is consistently one to two orders of magnitude.

The mistake is treating these tools as scripts you invoke. They are not scripts. They are servers that happen to write files instead of HTTP responses. Run them like servers.

Takeaways

  • For incremental workloads, a long-running build daemon beats fresh-process-per-request every time — usually by 10-100x, sometimes by 1,750x.
  • The expensive parts of any modern bundler (module graph, transformer warm-up, dependency resolution) are amortizable across requests. Pay them once per container lifetime, not once per edit.
  • Don't invent a rebuild trigger. The build tool already has a file watcher. Write the file; let the watcher fire.
  • AI agent loops benefit disproportionately. Agents make many small edits in close succession, exactly the access pattern that amortizes cold-start best.
  • The cost is statefulness: containers must outlive a single request, and you need a health check + restart loop because long-lived daemons wedge.
  • The pattern generalizes: tsc --watch, Vite, esbuild, webpack — same shape, same speedup. Treat your build tool as a daemon, not a script.

Try your own app idea

Describe your app in AppX →