Metro Bundler: The Complete Core-Level Guide
Audience: Engineers who want the full mental model — from first principles to internals.
Scope: General. Applies to any React Native / Metro project. No app-specific assumptions.
Last updated: June 2026
Shared from "Study" on Inkdown
Audience: Engineers who want the full mental model — from first principles to internals.
Scope: General. Applies to any React Native / Metro project. No app-specific assumptions.
Last updated: June 2026
Metro is a JavaScript bundler that builds a dependency graph from entry points, transforms every module in that graph, and serializes the result into one or more JavaScript bundles — optimized for incremental rebuilds during React Native development.
That sentence contains the entire job. Everything else is detail about how each word in that sentence works.
| Role | Description |
|---|---|
| Bundler | Combines many JS/TS files + npm packages into loadable output |
| Graph builder | Tracks every static import / require as edges between modules |
| Transformer orchestrator | Calls Babel (or custom transformers) per file, in parallel |
| Dev server | Serves bundles and assets over HTTP during development |
| Incremental build system | Rebuilds only the subgraph affected by a change |
Metro was created by Meta (Facebook) specifically for React Native. It is the default bundler for React Native and is also used by Expo for native and web targets.
| Thing | What actually does it |
|---|---|
| React Native itself | Native UI framework + bridge/Fabric |
| Hermes / JavaScriptCore | JS engines that execute the bundle Metro produced |
| Xcode / Gradle | Native compilers that build the .app / .apk shell |
| Babel | A compiler Metro invokes per file — not the bundler |
TypeScript (tsc) | Type checker; Metro strips types via Babel, does not type-check |
| npm / yarn / pnpm / Bun | Package managers that install deps Metro later resolves |
| Expo CLI | Orchestration layer that starts Metro and configures defaults |
| Watchman | A separate file-watching service Metro uses (optional but default) |
Simple analogy: Metro is the factory assembly line. Babel is one machine on that line. Hermes is the engine in the car after the factory ships it.
Webpack and Rollup existed when React Native launched, but they were built for the web. Mobile development has different constraints:
| Constraint | Why it matters | Metro’s response |
|---|---|---|
| Fast iteration | Devs save files hundreds of times per day | Incremental graph updates (DeltaBundler), transform cache |
| Large graphs | node_modules + app code = tens of thousands of files | Parallel workers, file map pre-indexing |
| No native ESM on older RN | RN historically used a custom require runtime | Numeric module IDs + __d wrapper |
| Platform forks | Same import, different file on iOS vs Android vs web | .ios.ts, .android.ts, .native.ts, .web.ts |
| Asset density | Images at multiple resolutions (@2x, @3x) | Asset registry + resolution variants |
| Dev on device | Phone pulls bundle over Wi‑Fi from laptop | HTTP dev server + HMR WebSocket |
Metro is not “Webpack for mobile.” It is a bundler designed for mobile dev loops from day one.
In development, Metro is a long-running server that keeps a warm graph in memory.
In production, Metro is a batch compiler that emits artifacts consumed by the native build.
Metro is not one monolith. It is a monorepo of focused packages, each owning one step:
| Package | Responsibility |
|---|---|
metro | Main orchestrator: runServer, buildGraph, DeltaBundler, dev server |
metro-config | Config merging, defaults, validation |
metro-resolver | Full Node-style resolution + RN extensions |
metro-transform-worker | Per-file transform in worker threads |
metro-file-map | Filesystem index (replaces older “Haste map” naming in places) |
metro-cache | Pluggable transform cache stores (file, HTTP) |
metro-runtime | require polyfill, __d, __r, async bundle loading |
metro-source-map | Composing source maps across transforms |
metro-symbolicate | Turn stack traces → original source locations |
metro-babel-transformer | Default Babel integration |
metro-minify-terser | Production minification |
Design principle: Resolver, transformer, and serializer are swappable. You can plug custom implementations via metro.config.js without forking Metro.
Everything Metro does reduces to building and updating a directed graph.
.ts file, an npm package entry, a JSON file, an image asset).import / require).| Type | Example | Treated as |
|---|---|---|
| Source file | App.tsx | Transformed JS module |
| Asset | logo.png | Asset registry entry, not executed JS |
| Empty module | Browser stub for fs | Zero-byte shim |
| Package entry | react-native | Resolved via package.json fields |
| Kind | Syntax | Graph behavior |
|---|---|---|
| Static ESM | import X from 'y' | Always an edge; target included in graph (or separate chunk on web) |
| Static CJS | require('y') | Same as static ESM for graph purposes |
| Dynamic | import('./chunk') | Async dependency; may become a separate bundle (platform-dependent) |
| Dynamic invalid | require('./' + variable) | Cannot resolve at build time; see dynamicDepsInPackages config |
Metro’s graph is built by static analysis of import/require in transformed AST output. It does not execute your code to discover dependencies.
A → B → A is allowed in JavaScript. Metro does not block cycles. They cause initialization order bugs (one module sees undefined exports from another). Metro can warn about require cycles in dev (configurable via requireCycleIgnorePatterns — by default node_modules cycles are suppressed).
A “bundle” is a reachable subgraph from one or more entry points, filtered by platform, dev/prod mode, and serializer options. You do not bundle “the whole repo” — only what is reachable.
Metro’s official model has three phases. In practice, resolution and transformation are heavily pipelined and parallelized.
Question answered: “What file on disk does './Button' or 'react-native' refer to?”
Question answered: “What JavaScript should the runtime execute for this file?”
Runs in worker threads (maxWorkers, default ~half of CPU cores). Two separate worker pools exist: one for transforms, one for file-map building.
Question answered: “How do I pack all modules into bytes the RN runtime understands?”
A full rebuild of a 50,000-module graph on every save would be unusable. Metro’s core innovation for dev speed is incremental graph maintenance via the DeltaBundler.
When you request a bundle (or HMR update), Metro does not always rewrite the entire output:
| Start type | What happens |
|---|---|
| Cold | Build file map, empty graph, transform everything reachable, populate caches |
| Warm | File map cached, graph in memory, transform cache on disk — only delta work |
--reset-cache / resetCache: true forces cold behavior for caches (not necessarily killing the server process).
Before resolving imports quickly, Metro needs to know what files exist without hitting the disk on every resolution.
metro-file-map builds an in-memory map of the project:
projectRoot + watchFoldersblockList regexes (hide paths from resolution)fileMapCacheDirectory (default: OS temp dir)The resolver’s fileSystemLookup(path) consults this map — not fs.existsSync per lookup. That makes resolution O(1) map lookup instead of O(disk I/O).
Metro historically supported Haste: importing modules by global name (import Foo from 'Foo') without relative paths. This requires hasteImplModulePath and is rare in modern projects. The file map still exposes resolveHasteModule / resolveHastePackage for this mode.
Resolution is the most common source of “Unable to resolve module” errors. Understanding the algorithm beats guessing.
Every resolve call carries a context including:
| Field | Meaning |
|---|---|
originModulePath | Absolute path of the file containing the import |
platform | ios, android, web, etc. |
dev | Development vs production |
sourceExts | Extensions to try, in order |
assetExts | Extensions treated as assets |
mainFields | package.json entry fields to read |
nodeModulesPaths | Extra lookup directories |
extraNodeModules | Package name → forced directory |
resolveRequest | Custom resolver hook |
fileSystemLookup | Map-based existence check |
For import moduleName from originModulePath:
For platform android and sourceExts = ['js', 'jsx', 'ts', 'tsx'], Metro tries in order:
First match wins. This is how Button.ios.tsx and Button.android.tsx work.
When resolving node_modules/react-native:
package.json has "exports" and exports are enabled → resolve via conditional exports (import vs require, react-native, browser, default).main / react-native field path.Since Metro 0.82+, unstable_enablePackageExports defaults to true.
| Behavior | Detail |
|---|---|
Honors exports map | Subpaths like pkg/dist/internal may be blocked |
| Conditional exports | Asserts require or import based on dependency type |
| Platform conditions | Web adds browser condition; RN adds react-native |
| No platform extensions inside exports | If exports matches, .ios.ts variants are NOT tried (Node compatibility) |
| Fallback | If no exports match, warns and falls back to legacy resolution |
Packages can remap or stub modules for non-Node environments:
false → Metro’s empty module (a built-in no-op shim).
| Type | Meaning |
|---|---|
{ type: 'sourceFile', filePath } | Transform as JS module |
{ type: 'assetFiles', filePaths: [...] } | Register as asset(s) |
{ type: 'empty' } | Use empty module shim |
Metro cannot statically resolve this. Config dynamicDepsInPackages:
'throwAtRuntime' (default) — bundle succeeds; throws when line runs'reject' — fail the buildFor each module, the transformer returns:
inlineRequires hints, asset references, etc.Metro does not run tsc for emit. TypeScript types are erased by Babel. Type-checking is a separate step (tsc --noEmit) you run in CI or your editor.
Common Babel plugins in RN projects:
@babel/preset-typescript — strip types@babel/preset-react — JSXreact-native-reanimated/plugin — must be last in plugin listbabel-plugin-module-resolver — if used for aliases (Expo often uses tsconfig paths instead)| Setting | Effect |
|---|---|
maxWorkers | ~os.availableParallelism() / 2 by default |
maxWorkers: 1 | Workers run in main process (debugging) |
stickyWorkers: true | Same file → same worker (faster warm transforms) |
Two worker pools: transform pool and file-map pool, each sized by maxWorkers.
getTransformOptionsA callback Metro invokes per bundle request to set:
| Option | Purpose |
|---|---|
inlineRequires | Lazy require() inside modules (prod startup optimization) |
nonInlinedRequires | Packages to exclude from inlining |
preloadedModules | Modules to eagerly execute in RAM bundles |
ramGroups | Group modules for RAM bundle segmentation |
React Native’s release builds often enable inline requires to improve startup by deferring module initialization until first use.
transformer.babelTransformerPath or top-level transformerPath can point to a custom module implementing Metro’s transformer interface — used for MDX, CSS, SVG-as-components, etc.
Serialization assigns numeric module IDs and emits the module factory table the runtime executes.
Each module becomes a call to __d (define):
| Piece | Role |
|---|---|
__d | Register module factory |
moduleId | Unique integer in this bundle |
dependencyMap | Maps local require indices → module IDs |
| Factory function | Executes once when module is first required |
If App.tsx imports react and ./Button:
The runtime resolves r(d[0]) → require(0) → executes module 0’s factory.
createModuleIdFactory (stable IDs matter for RAM bundles and HMR)processModuleFilter to exclude modulesrunModule / runBeforeMainModule for side-effect ordering| Output | When |
|---|---|
| Single JS string | Default dev + prod |
| RAM bundle (binary) | iOS release optimizations |
| Multiple chunks | Web + dynamic import() |
| Source map file | Production debugging |
The bundle is not native ESM. It is an IIFE that installs a custom module system from metro-runtime.
| Symbol | Purpose |
|---|---|
__d | Define a module |
__r / require | Load module by ID (with cache) |
__c | Clear module cache (HMR) |
global | RN’s global object |
Once require(5) runs module 5’s factory, the exports object is cached. Subsequent require(5) returns cached exports. HMR uses __c to invalidate specific IDs before re-executing updated factories.
__DEV__ globalMetro defines __DEV__ as true in development bundles and false in production. Dead code wrapped in if (__DEV__) can be stripped in prod minification.
asyncRequire and dynamic importimport('./chunk') is transformed to use asyncRequire from metro-runtime, which loads additional bundles at runtime (platform-specific behavior).
Metro supports three bundle formats:
__d(...)index.bundle?platform=ios&dev=trueBinary format with:
| Section | Content |
|---|---|
Magic 0xFB0BD1E5 | Format identifier |
| Offset table | (offset, length) pairs per module |
| Startup code | Eagerly loaded preamble |
| Module bodies | Null-terminated module code |
Why: Load any module in O(1) time via offset table — important when entire bundle is in memory.
js-modules/<id>.jsUNBUNDLE marker file at root with magic numberModern Hermes + improved startup have reduced RAM bundle dominance, but the formats remain in Metro’s serializer.
Metro’s dev server (default port 8081) is an HTTP server built into the metro package.
| Request | Purpose |
|---|---|
GET /index.bundle?platform=ios&dev=true&minify=false | Full JS bundle |
GET /index.map?platform=ios&dev=true | Source map |
GET /assets/... | Serve image/font assets |
GET /symbolicate | POST stack frames → original locations |
WebSocket /hot | HMR events |
| Param | Effect |
|---|---|
platform | ios, android, web — drives resolution |
dev | true / false |
minify | Enable minification |
modulesOnly | Return only specific modules (HMR) |
runModule | Whether to execute entry after define |
shallow | Graph depth control (advanced) |
server.enhanceMiddleware lets you attach custom HTTP middleware (proxy, auth, custom endpoints).
One Metro server can serve simultaneous iOS simulator, Android emulator, and web — each requests a different platform graph. Graphs overlap heavily but are not identical (platform files differ).
Three related but distinct mechanisms:
| Mechanism | Layer | What happens |
|---|---|---|
| Live reload | Dev server | Full page/app reload on any change |
| HMR (Hot Module Replacement) | Metro | Swap changed modules in running bundle |
| Fast Refresh | React | HMR + preserve React component state |
Fast Refresh is enabled by a Babel plugin (react-refresh/babel) coordinated with Metro HMR.
| Edit target | Result |
|---|---|
| File exporting only React components | Hot swap, state preserved |
| File with non-component exports | Re-run module + all importers |
| File imported by non-React code | Full reload fallback |
| Syntax error | Redbox; fix + save recovers without manual reload |
| Runtime error in component | Redbox; fix remounts component |
Hook behavior during Fast Refresh:
useState / useRef — preserve valuesuseEffect / useMemo / useCallback — always re-run during refresh (even empty deps)Force remount: add // @refresh reset in a file.
metro.config.js changes → restart Metroapp.json native config) → full reloadMetro has multiple independent caches. Confusing them causes “I cleared cache but it’s still broken.”
| Property | Detail |
|---|---|
| Key | Hash of file content + relevant config + transformer version |
| Value | Transformed code + source map |
| Stores | cacheStores — default FileStore in temp dir |
| Override | cacheVersion string appended to all keys |
| Reset | --reset-cache, resetCache: true, delete metro-* in tmpdir |
| Property | Detail |
|---|---|
| Location | fileMapCacheDirectory (default OS tmp) |
| Invalidates | watchFolders change, blockList change, structural FS changes |
| Reset | resetCache: true |
HttpStore / HttpGetStore in cacheStores:
metro build → populates remote cacheFileStore listed first for speed| Change | Transform cache | File map | In-memory graph |
|---|---|---|---|
Edit one .tsx file | That file only | No | Dirty subgraph |
npm install | Potentially many | Yes | Full rebuild |
metro.config.js | All | Maybe | Full rebuild |
babel.config.js | All | No | Full rebuild |
package.json exports change | Affected packages | Yes | Affected subgraph |
Watchman is Meta’s file-watching service. Metro prefers it over Node’s fs.watch because it scales to huge trees.
| Setting | Effect |
|---|---|
watcher.useWatchman: true | Default when Watchman installed |
watcher.useWatchman: false | Fall back to Node crawler (slower) |
Troubleshooting: watchman watch-del-all clears stale watches.
watchFolders vs watchingDespite the name, watchFolders defines visibility, not just watching. Files outside projectRoot + watchFolders cannot be resolved even in CI offline builds.
React Native’s platform system is implemented inside Metro’s resolver, not in the RN runtime.
platform=ios)For file utils.ts:
For platform=web:
Same import statement. Three different resolved files possible.
platforms configDefault: ['ios', 'android', 'windows', 'web']. Add custom platforms if needed.
Assets (images, fonts, video) are not executed as JS. They flow through a parallel registry.
icon.png matches assetExtsRESOLVE_ASSET collects all variants: icon.png, icon@2x.png, icon@3x.png| Mode | Behavior |
|---|---|
| Dev | Asset served from http://localhost:8081/assets/... |
| Prod | Assets copied into app package; IDs map to packaged paths |
assetResolutionsDefault scale suffixes: @2x, @3x, etc. Customizable in config.
Metro composes source maps across the transform chain so debuggers show original TypeScript/JSX.
| Consumer | Purpose |
|---|---|
| Chrome / Safari devtools | Breakpoints in original source |
| React Native redbox | Error overlay with TS lines |
metro-symbolicate | Convert production stack traces |
Often external (index.map) or hidden (not referenced in bundle) to reduce download size while keeping maps for crash reporting.
| Dimension | Development (dev=true) | Production (dev=false) |
|---|---|---|
__DEV__ | true | false |
| Minification | Off | On (terser) |
| Inline requires | Usually off | Often on (RN release) |
| Source maps | Cheap / inline | External / optimized |
| Error messages | Verbose | Stripped |
Dead code in if (__DEV__) | Kept | Removed |
| Bundle size | Large | Smaller |
| Transform cache | Heavy use | CI + release pipeline |
| Fast Refresh | On | N/A |
NODE_ENV vs __DEV__Metro sets __DEV__ from the dev bundle param. process.env.NODE_ENV may also be inlined by Babel — they should align but are set by different mechanisms.
import('./heavy') typically produces:
asyncRequire / web chunk loaderNative code splitting is less mature than web. Dynamic import is transformed but behavior depends on RN version and configuration. Historically, RAM bundles + inline requires were the native startup strategy rather than web-style chunks.
__loadBundleAsyncModern proposal for explicit lazy bundle loading on native — replaces older asyncRequireModulePath patterns.
Metro’s output is JavaScript text. Hermes is a separate step in release builds.
| Engine | Metro relationship |
|---|---|
| Hermes | Consumes Metro JS bundle → bytecode |
| JSC | Executes Metro JS bundle directly |
| V8 (web) | Browser executes Metro web bundle |
Metro does not know about Hermes. The native build pipeline invokes hermesc after bundling.
Config file priority (highest first):
metro.config.js / .cjs / .mjsmetro.config.tsmetro.config.json.config/metro.*package.json → "metro" field| Option | Purpose |
|---|---|
projectRoot | Root of the Metro project |
watchFolders | Additional visible directories |
cacheStores | Transform cache backends |
cacheVersion | Force cache invalidation |
resetCache | Clear caches on startup |
maxWorkers | Parallelism |
stickyWorkers | Pin files to workers |
reporter | Progress / event reporting |
transformerPath | Custom transformer module |
resolver| Option | Purpose |
|---|---|
sourceExts | Try extensions in order |
assetExts | Non-JS file extensions |
resolverMainFields | ['react-native', 'browser', 'main'] in RN |
blockList | Exclude paths from graph |
extraNodeModules | Force package locations |
nodeModulesPaths | Extra node_modules roots |
resolveRequest | Custom resolution hook |
disableHierarchicalLookup | Skip upward node_modules walk |
unstable_enablePackageExports | Honor exports field |
unstable_conditionNames | Global export conditions |
unstable_conditionsByPlatform | Per-platform export conditions |
platforms | Supported platform strings |
transformer| Option | Purpose |
|---|---|
babelTransformerPath | Custom Babel integration |
getTransformOptions | Per-bundle inlineRequires, RAM groups |
dynamicDepsInPackages | throwAtRuntime vs reject |
unstable_allowRequireContext | Webpack-style require.context |
serializer| Option | Purpose |
|---|---|
createModuleIdFactory | Stable / deterministic module IDs |
processModuleFilter | Exclude modules from output |
customSerializer | Replace entire serialization |
getRunModuleStatement | Customize entry execution |
isThirdPartyModule | Mark node_modules for optimizations |
server| Option | Purpose |
|---|---|
port | Default 8081 |
enhanceMiddleware | Custom HTTP middleware |
watcher| Option | Purpose |
|---|---|
watchman.deferStates | Watchman perf tuning |
healthCheck.enabled | Watchman health checks |
Monorepos break Metro if configured naively.
| Problem | Cause | Fix |
|---|---|---|
| Cannot resolve workspace package | Package outside projectRoot | Add to watchFolders |
| Duplicate React | Two copies in different node_modules | blockList, hoisting, extraNodeModules |
| Symlinked packages not found | Symlink target outside watch | Include real path in watchFolders |
| Slow startup | Watching entire repo | Narrow watchFolders to needed packages |
enableGlobalPackagesWhen true, any package.json with a name field under watchFolders can be imported by package name (workspace-style).
Rules:
context.resolveRequest for fallbackserializer.customSerializer receives the full graph and can emit any output format — used by advanced tooling, not typical apps.
| Optimization | How |
|---|---|
| Minification | terser removes dead code in production |
__DEV__ stripping | if (__DEV__) blocks removed when dev=false |
| Inline requires | Defer module init until needed |
| processModuleFilter | Manual exclusion |
| Limitation | Impact |
|---|---|
| No full ESM tree shaking | import { a } from 'barrel' may still pull entire barrel’s re-exports into graph |
| CommonJS interop | require() dynamic nature limits static analysis |
| Side-effect-free marking | No sideEffects: false equivalent as aggressive as Rollup |
react-native-bundle-visualizer)A typical resolution error:
Read four things:
X)Y)| Symptom | Likely cause |
|---|---|
Unable to resolve module | Missing dep, wrong alias, exports blocked |
Duplicate React | Two React copies in graph |
| Stale code after edit | Cache corruption |
| Works on iOS, fails web | .native.ts or Node API without web shim |
| OOM during bundle | Graph too large — dynamic import |
| Slow rebuilds | Watchman broken, huge watchFolders |
Production crashes show minified names. POST stack frames to Metro’s /symbolicate endpoint (or use RN tooling) with the release source map.
| Dimension | Metro | Webpack | Vite | Rollup |
|---|---|---|---|---|
| Primary target | React Native | Web (universal) | Web | Libraries / web |
| Dev model | Incremental graph + HTTP | Bundle or lazy compile | Native ESM + esbuild prebundle | N/A (build tool) |
| Module system in output | Custom require IDs | Various | ESM in dev | ESM |
| HMR | Yes + Fast Refresh | Yes | Yes | No |
| Code splitting | Web-strong; native differs | Mature | Mature | Mature (ESM) |
| Tree shaking | Limited | Strong (ESM) | Strong | Strongest |
| Config complexity | Moderate | High | Lower (web) | Moderate |
| Incremental | Core design | Added (persistent cache) | Dev server native ESM | Per-build |
Why not use Webpack for RN? Historical integration, custom module runtime, platform resolution, and dev incremental performance on mobile-scale graphs.
Concrete scenario: you change Button.tsx in a running dev session.
| Term | Definition |
|---|---|
| Asset | Non-JS file (image, font) registered by Metro, executed as numeric ID |
| Async dependency | Target of dynamic import() — may be separate chunk |
| blockList | Regex excluding paths from Metro’s file map |
| Bundle | Packed JS output containing module factories + runtime |
| Chunk | One of multiple output bundles (code splitting) |
| Conditional exports | package.json exports branches on import/require/platform |
| Delta | Set of graph changes since last bundle (HMR/incremental) |
| DeltaBundler | Metro subsystem maintaining incremental dependency graph |
| Dependency graph | Modules (nodes) + imports (edges) reachable from entry |
| Empty module | No-op shim when browser field maps to false |
| Entry point | Where graph traversal begins (e.g. index.js) |
| Fast Refresh | React-aware HMR preserving component state |
| File map | In-memory index of project files (metro-file-map) |
| Haste | Legacy global module name imports (opt-in) |
| Hermes bytecode (HBC) | Compiled output of Hermes, post-Metro |
| HMR | Hot Module Replacement — swap modules without full reload |
| Inline requires | Transform require to lazy calls inside module body |
| Module ID | Integer identifying a module in the bundle |
| metro-runtime | __d, __r, __c — module system in the bundle |
| Platform resolution | Choosing .ios.ts / .web.ts variants |
| RAM bundle | Binary or multi-file bundle format for O(1) native module load |
| Resolver | Import string → absolute path algorithm |
| Serializer | Graph → bundle bytes |
| Source map | Maps bundled JS back to original source |
| Transform cache | Disk/memory store of per-file Babel output |
| Transformer | Per-file compiler (typically Babel) |
| watchFolders | Directories Metro can see beyond projectRoot |
| Watchman | File watching service used by Metro |
General-purpose Metro reference. File: ~/Downloads/metro-bundler-deep-dive.md