skip to content

Why does React Native's Metro bundler not tree-shake unused exports the way many web bundlers do, and what dead code does it still remove?

level: middleimportance: should knowfreq 30%

answer

  1. whole module in, even for one export
  2. per-file transform, no whole-graph pass
  3. __DEV__ and Platform.OS inlined per file
  4. constant folding in production only
  5. Expo: experimental EXPO_UNSTABLE_TREE_SHAKING

basics

~20 s

Metro transforms and caches each file on its own, so no step looks at which exports other files use: a reached module is bundled whole. It still removes dead branches per file by inlining DEV and Platform.OS, constant folding and minification.

solid answer

~40 s

Tree shaking needs a whole-graph view: which exports of each module are imported anywhere. Metro deliberately avoids that. It transforms each file in isolation, in parallel workers, and caches the result per file, which is what makes development rebuilds fast; by the time modules are serialized they are opaque factories. So importing one function from a large module includes the entire module. What Metro does remove is dead code inside a file: it inlines `__DEV__` and, when `Platform` is imported directly from `react-native`, `Platform.OS`, then in production builds runs a constant-folding pass. Dependencies are collected after that pass, so a `require` inside a removed branch drops out of the graph too. The minifier removes the rest. Expo adds experimental cross-module tree shaking behind `EXPO_UNSTABLE_TREE_SHAKING=1`, production only.

code

tsx · 12 lines
tsx
import {Platform} from 'react-native';

export function setupDebugTools(): void {
  if (__DEV__) {
    // Folded away in production: this require never enters the graph.
    require('./debugPanel').install();
  }
  if (Platform.OS === 'ios') {
    // Inlined per platform: the Android bundle drops this branch.
    require('./iosOnlyHaptics').prime();
  }
}

go deeper

for a junior

Recall that Metro bundles every reached module in full and that DEV blocks are removed from production builds.

for a middle

Explain why per-file transformation rules out cross-module tree shaking, and list the per-file removals: inlining, constant folding and minification.

for a senior

Shape imports for bundle size: avoid barrel files, gate development tools behind DEV requires, and measure the shipped modules.

for a principal

Weigh whether Expo's experimental tree shaking is worth its risk in production against simpler import discipline and the development-speed design it trades against.

## What tree shaking needs **Tree shaking** means removing exports that nothing imports. To do it a bundler must know, for every module, which of its exports are used by any other module in the whole graph, and it must be able to remove unused ones without changing behaviour. That requires two things: static `import`/`export` syntax it can analyse, and a **whole-graph pass** after every module is known. Many web bundlers are built around exactly that pass. ## Why Metro does not do it Metro was designed for a different priority: fast, incremental development builds. Its transformation stage works on **one file at a time**: - each file is compiled in a worker process by the Babel transformer, independent of every other file; - the output is cached against that file's contents and the config, so an edit re-transforms only one file; - by default `@react-native/babel-preset` converts `import`/`export` into CommonJS-style `require` calls during that per-file pass; - each module is then wrapped in a `__d(...)` factory, which the serializer only has to concatenate. Nothing in that pipeline asks "which exports does anyone use?", and by serialization time each module is an opaque function. So the rule for Metro is simple: **if a module is reached, all of it is bundled.** Importing one helper from a large utility module ships the whole module, and a barrel file (`index.ts` re-exporting a library) pulls in everything it re-exports. ## What Metro still removes Metro does remove dead code, just not across modules. 1. **`__DEV__` inlining.** The inline pass replaces `__DEV__` with `true` or `false` for the build. 2. **Platform inlining.** When a file imports `Platform` directly from `react-native`, `Platform.OS` (and simple `Platform.select` calls) are replaced with the platform being bundled. 3. **Constant folding** in production builds: `if (false) { … }` and `'ios' === 'android' ? a : b` collapse to what actually runs. 4. **Dependency collection happens after folding.** A `require('./DebugPanel')` inside an `if (__DEV__)` block disappears from the production graph, so that module is not bundled at all. 5. **Minification** (default `metro-minify-terser`) removes unreachable code and unused local bindings inside each file. So within a file, dead branches go; across files, unused exports stay. ## Expo's experimental tree shaking Expo CLI builds on Metro and offers more, experimentally, since SDK 52: | Step | How it is enabled | |---|---| | Metro handles `import`/`export` itself | `experimentalImportSupport`, on by default in SDK 54 and later | | Keep modules until the whole graph exists | `EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1` | | Remove unused imports and exports | `EXPO_UNSTABLE_TREE_SHAKING=1` | Expo documents it as production only and very experimental, because it delays part of the transformation until the entire graph is known, which is exactly the property Metro's per-file design avoids. Expo also documents **platform shaking**, which removes `Platform.OS` branches for the other platform. ## Practical consequences - Import the specific module you need rather than a library's root barrel when the library supports it. - Do not rely on "unused code will be dropped": code that is reached is shipped. - Guard development-only modules with `if (__DEV__)` and a `require` inside the branch, so the production graph never includes them. - Measure what ships instead of assuming: the bundle output shows every module included. ## Why the tradeoff is reasonable on mobile A mobile bundle is loaded from the device's disk, not downloaded on every visit, so a larger bundle costs install size and startup work rather than network time per session. That makes the per-file design a defensible choice: development speed is paid for every edit, while the size of unused code is paid mostly at startup. It is also why large apps care more about when modules are evaluated than about removing every unused export. ## How to answer in an interview Explain the design tradeoff, not just the fact: Metro gives up whole-graph optimisation to keep per-file transformation parallel and cacheable, which keeps development fast. Then show you know what it still does per file (inlining, constant folding, minification) and that Expo offers experimental tree shaking in production. The weak answer says Metro "is just old" or that it tree-shakes like any modern bundler.

  • How do you keep a development-only module out of a React Native production bundle?
    Require it inside an `if (__DEV__)` block rather than importing it at the top of the file. In production Metro inlines `__DEV__` as `false` and folds the branch away before collecting dependencies, so the module is never added to the graph.
  • Why does a Platform.OS check behind a re-exported helper not get removed per platform?
    Metro only inlines `Platform.OS` when the file imports `Platform` directly from `react-native`. A helper that re-exports the value from another module is opaque to that per-file pass, so both branches stay in both bundles.

A moving company that packs every box you point at without opening it: you get the whole box even if you needed one item. It does, however, throw out anything you labelled 'development only' before it seals the box.

saying these in an interview costs you the question

  • Metro tree-shakes unused exports just like other modern bundlers
  • Importing one function only bundles that function's code
  • Metro removes no dead code at all, so __DEV__ blocks always ship
  • Expo's tree shaking is on by default for every build
  • Metro skips tree shaking only because Hermes needs it off