Benchmarks

What the cache and the thin server actually cost

Two measured cases on real Expo SDK 54 apps. Case 1 is the cross-project transform cache: separate projects with the same dependencies share one content-addressed store, so the second and third pay almost nothing. Case 2 asks the follow-up question — what happens when you install a new package?— and compares the two serving paths (Metro vs jetplane’s thin server) on memory.

Method

Every number below is measured on the same machine — Apple Silicon · macOS. Memory is resident set size of the whole dev-server process tree (psRSS). Cache hits/misses come from the transform worker’s own telemetry (one H/Mper module transform, tallied per bundle). A “module” is one file the bundler transforms; a “miss” means it actually ran the transform, a “hit” means it was reused from the shared cache. Each project is wired the way jetplane init writes metro.config.js(jetplane’s worker chained over Expo’s default transformer).

Case 1 · Cross-project cache

Three identical-dependency SDK 54 apps, one shared cache starting empty. Build project A, then B, then C. A is cold and pays the full transform cost while populating the store; B and C reuse it — the key is root-independent, so the same module transformed under A is reused under B and C.

Transforms reused across projects — hits vs misses
cache hitmiss (transformed)
project A
cold — builds cache
0 hits · 1,442 miss · cold — builds the cache
project B
same deps
1,440 hits · 2 miss · 99.9% reused
project C
same deps
1,440 hits · 2 miss · 99.9% reused

B and C miss only the 2 modules that genuinely differ per project; everything else is a hit.

Cold-cache bundle time vs warm-cache (ms · lower is better)
project A
cold
4,973 ms
project B
warm
1,710 ms
project C
warm
1,799 ms

The ~2.8× speedup is explained by the hit count — 99.9% of transforms never ran.

projectmoduleshitsmisseshit-ratebundle time
A (cold)1,44201,4424,973 ms
B1,4421,440299.9%1,710 ms
C1,4421,440299.9%1,799 ms
Takeaway. Same dependencies ⇒ one shared transform cache. The first project on a machine pays the cold cost once; every same-dep project after it reuses ~99.9% of the work.

Case 2 · Installing a new package

Same setup — three identical apps, shared cache built cold by proj-a — but now proj-b installs and imports one unique package (ms) and proj-c a different one (dayjs). The question: does adding a package rebuild the transform cache of node_modules from scratch, or only for that package?

After installing one unique package — what re-transforms
cache hitmiss (transformed)
proj-a
default expo · cold
0 hits · 1,442 miss · cold — builds the cache
proj-b
+ ms
1,440 hits · 3 miss · 99.8% reused
proj-c
+ dayjs
1,440 hits · 3 miss · 99.8% reused

proj-b/proj-c re-transform only 3 of 1,443 modules (0.2%): the new package (node_modules/ms/index.js), the one screen edited to import it, and expo-router’s generated route module (which re-hashes because app source changed). No other node_modulesmodule runs.

projectunique pkgmoduleshitsmisseshit-ratebundle time
proj-a1,44201,442— (cold)3,122 ms
proj-bms1,4431,440399.8%1,333 ms
proj-cdayjs1,4431,440399.8%1,114 ms
Takeaway. Installing a package does not rebuild node_modules. The cache is keyed per module by source bytes, so only the new package (plus the app file that imports it) is a miss — the other ~1,440 modules are reused untouched.

Case 2 · Thin serve vs Metro memory

The same three projects, two serving paths. Metro (expo start) is the normal dev server: it transforms and assembles the graph on every bundle request. jetplane thin servebuilds the bundle image once, then replays it mmap’d from a no-Metro process — so no transform happens at serve time.

Dev-server memory (MB · lower is better) — Metro vs thin serve
IdlePeak
proj-a · Metro
expo start
654
2,965
proj-a · thin
jetplane serve
139
156
proj-b · Metro
+ ms
658
1,612
proj-b · thin
+ ms
140
156
proj-c · Metro
+ dayjs
657
1,561
proj-c · thin
+ dayjs
140
156

Linear scale to 900 MB; Metro’s peaks run off the axis (torn end) with their true value. thin serve in colour, Metro muted. thin peak is steady-state serving — the one-time image build is excluded (see caveats).

projectMetro idleMetro peakthin idlethin peakthin (bun only)
proj-a654 MB2,965 MB139 MB156 MB108 MB
proj-b658 MB1,612 MB140 MB156 MB108 MB
proj-c657 MB1,561 MB140 MB156 MB108 MB
Takeaway. thin serve is flat at ~140 MB idle / 156 MB peak regardless of the project or the new package, because it serves a pre-built bundle. Metro holds ~655 MB idle and spikes to 1.5–3 GB on a cold bundle. Idle is ~4.7× lighter; peak is ~10× (warm) to ~19× (cold) lighter. The ~140 MB tree is a ~30 MB Node launcher plus the ~108 MB bun server.

Caveats & honest framing

These are different roles, so read the numbers with the following in mind:

Reproduce

Case 1 is a committed harness — three SDK-54 apps under bench/, built cold→warm with hit-rate read from the worker telemetry:

node bench/xproject-hitrate.mjs

Case 2 clones one app three times (APFS copy-on-write), installs one unique package into two of them, and measures both the Metro and thin-serve paths. Full method and harnesses are in bench/ — and the underlying mechanism is described in the docs.