Quick start

Add jetplane to an existing Expo project. No workflow change — you keep using expo start.

# 1. install as a dev dependency (Metro resolves the plugin from here)
npm install -D jetplane

# 2. wire it into metro.config.js
#    (creates the file, or prints the 2 lines to add if you already have one)
npx jetplane init

# 3. run your app as usual — now cross-project cached
npx expo start

jetplane init adds two lines to your Metro config:

// metro.config.js
const { getDefaultConfig } = require('expo/metro-config')

const config = getDefaultConfig(__dirname)
config.transformerPath = require.resolve('jetplane/transformer') // the Metro plugin
config.cacheStores = []                                          // jetplane owns caching

module.exports = config

That’s the whole integration. The first bundle populates a shared, content-addressed cache under ~/.jetplane; every other same-dep project (and every restart) reuses it, so cold bundles stop re-transforming node_modules. Requires Expo SDK 54+, Node 20+.

Want the low-memory dev server too? One command does the whole fresh-project setup — wire the plugin, install deps, build the bundle once, then serve it from a ~40 MB no-Metro process with live HMR (needs Bun):

npx jetplane dev

Scan the QR in Expo Go and edit app/(tabs)/index.tsx — it hot-reloads, served entirely from the thin process. If the port is busy it steps to the next free one. Three commands, from least to most: jetplane init (wire the cache only), jetplane serve (serve an already-set-up project), and jetplane dev (the unified setup + serve above; jetplane start is an alias). See Thin dev server for how it works.

What it is

jetplane is two things: a Metro plugin (a custom transformerPath) that gives React Native a cross-project transform cache Metro’s own root-dependent cache can’t provide; and a lightweight, no-Metro dev server that serves a pre-built bundle from a ~40 MB process with live HMR, for running many environments per machine.

It augments Metro — it is not a replacement for the Expo CLI. The plugin mode is fully drop-in with expo start; the thin-serve mode is a separate, experimental command. See the full comparison. Open source under MIT.

The problem

Metro runs one process per dev server. Idle it holds ~325 MB, but the moment it builds a bundle with a cold cache it transiently spikes to ~2 GB and then holds a ~700 MB floor. N servers scale linearly with zero sharing — measured. The trigger that OOMs a fleet node is a burst of concurrent cold bundles (a deploy, a cache eviction, a mass branch update), each costing ~2 GB.

First principle

For one Expo app, node_modules is 539 MB / ~33,000 files / 365 packages — but only 65 packages / 1,664 modules / ~8 MB are actually reachable into the bundle. ~98.5% of node_modules never ships. The code you edit is ~19 files. So a dev bundle is really two layers with opposite properties: an immutable vendor layer (huge, identical across branches, never edited) and a mutable app layer (tiny, unique, edited constantly). Metro treats them the same — one watcher, one graph, one heap, per process. jetplane exploits the asymmetry.

Architecture

Cross-project cache. Transforms are content-addressed by source bytes (root-independent), so the same vendor module transforms once and is reused across different projects. Metro’s own transform cache keys are root-dependent, so it cannot do this — proven by a second project doubling Metro’s cache instead of reusing it. jetplane injects the cache at Metro’s transformer seam (a custom transformerPath) that normalizes the project root out on write and rehydrates it on read.

Shared transform service. babel’s ~150 MB resident cost is the real per-project memory hog, not the vendor. One shared service pays it once and serves N thin servers, so per-project memory stays flat.

Thin, no-Metro server. The pre-built bundle is served from a thin Bun process that mmaps it (shared physical pages across processes). No per-project Metro → ~40 MB per environment. The build (heavy, once) happens at container-build / pre-warm time — matching a fleet that ships a pre-built cache.

HMR

The pre-built bundle carries __d(factory, id, [deps], "path") for every module, so path→id, id→deps and inverse-deps are recovered by parsing it. The thin server’s /hot WebSocket speaks Metro’s protocol (register-entrypointsbundle-registered, thenupdate-start/update/update-done). On an edit it transforms the file (hot, React Refresh), wraps it with the right id + dependencyMap + inverse-deps, and sends any new helper modules the transform pulled in asadded. Validated on device in Expo Go — the title hot-swaps without a reload.

Thin dev server (experimental)

Beyond the plugin, jetplane can serve a pre-built bundle from a ~40 MB no-Metro process with live HMR — for running many environments per machine. One command sets it up:

npx jetplane dev

jetplane dev is the unified one-liner for a fresh project: it (1) ensures the plugin is in your metro.config.js, (2) installs dependencies if needed, (3) builds a device-bootable bundle once (running Metro a single time), then (4) serves it from the thin, no-Metro server and prints a QR for Expo Go — edit app/(tabs)/index.tsx and it hot-reloads. Requires Bun; it replaces the dev-server role, not the Expo CLI. (jetplane start is an alias.)

Native and web, one server. The build captures both a device bundle and a self-contained web bundle, and the thin server serves each target from the same process: Expo Go loads the device manifest, and a browser at http://localhost:<port> gets the web HTML shell + bundle. HMR works for both — each connected client is tagged by platform (native vs web) and gets an update built against its own bundle. The dev server also takes interactive keys like expo start: w opens web, i the iOS simulator, a Android, ? help, q quit.

The three commands, from least to most: jetplane init just wires the cache into your config; jetplane serve runs the thin server for a project that’s already set up; jetplane dev does the whole fresh-project setup and then serves. Running jetplane with no argument prints help.

Benchmark methodology

All numbers are resident memory of the whole dev-server process tree (ps RSS), on Apple Silicon / macOS 15. Memory is separated into idle vs peak (Metro’s cold bundle; others under first load). Cache hit-rate is measured by the transformer worker’s own hit/miss telemetry, reset per bundle. Harnesses and raw results are in bench/ (RESULTS.md, METRO-CACHE-RESULTS.md, PATH2-FINDINGS.md,ON-DEVICE-VALIDATION.md).

Status & roadmap

Open source under MIT and published on npm (jetplane). Shipping today: the Metro plugin (cross-project cache, drop-in with expo start) — measured at a 99.9% cross-project hit-rate on device. The thin dev server + HMR is experimental.

On the roadmap: multi-level new-dep + deletion handling in HMR, routing HMR transforms through the shared service, closing the 0.2% worklet path-normalization gap, app-layer cache-vary for env inlining, and a first-class jetplane serve command. Contributions welcome on GitHub.