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.
B and C miss only the 2 modules that genuinely differ per project; everything else is a hit.
The ~2.8× speedup is explained by the hit count — 99.9% of transforms never ran.
| project | modules | hits | misses | hit-rate | bundle time |
|---|---|---|---|---|---|
| A (cold) | 1,442 | 0 | 1,442 | — | 4,973 ms |
| B | 1,442 | 1,440 | 2 | 99.9% | 1,710 ms |
| C | 1,442 | 1,440 | 2 | 99.9% | 1,799 ms |
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?
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.
| project | unique pkg | modules | hits | misses | hit-rate | bundle time |
|---|---|---|---|---|---|---|
| proj-a | — | 1,442 | 0 | 1,442 | — (cold) | 3,122 ms |
| proj-b | ms | 1,443 | 1,440 | 3 | 99.8% | 1,333 ms |
| proj-c | dayjs | 1,443 | 1,440 | 3 | 99.8% | 1,114 ms |
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.
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).
| project | Metro idle | Metro peak | thin idle | thin peak | thin (bun only) |
|---|---|---|---|---|---|
| proj-a | 654 MB | 2,965 MB | 139 MB | 156 MB | 108 MB |
| proj-b | 658 MB | 1,612 MB | 140 MB | 156 MB | 108 MB |
| proj-c | 657 MB | 1,561 MB | 140 MB | 156 MB | 108 MB |
Caveats & honest framing
These are different roles, so read the numbers with the following in mind:
- The thin peak is steady-state serving. On a new package the lockfile + app source change, so the next
jetplane serverebuilds the bundle image once — a Metro build with a transient peak in the proj-b/proj-c range (~1.6 GB, ~1.3 s, riding the incremental cache) — then settles back to ~108 MB. thin serve trades a one-time rebuild for a permanently low steady state. - Bumping the transformer invalidates everything.The incremental win in Case 2 holds for a normal leaf dependency. If an install bumps the upstream transformer (
nativewind/react-native-css-interop/@expo/metro-config) or the Metro/Babel toolchain, the cache key changes and everything re-transforms — by design, since a different transformer can produce different output. - Absolute numbers are machine-dependent. These were captured under load on one machine; expect different absolutes elsewhere. The ratios (and the flat-vs-spiky shape) are the point.
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.mjsCase 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.