A React Native release build on CI is killed for running out of memory while Metro bundles JavaScript. How is Metro's maxWorkers involved, and how would you tune it?
answer
- release builds run Metro's bundle command
- default workers from os.availableParallelism()
- two pools, each sized maxWorkers
- --max-workers or maxWorkers in config
- Gradle extraPackagerArgs, Xcode EXTRA_PACKAGER_ARGS
basics
~20 sMetro runs transformation in parallel workers sized by maxWorkers, which defaults from os.availableParallelism(). A CI machine with many cores but little RAM runs out of memory. Lower maxWorkers in the config or pass --max-workers to the bundle step.
solid answer
~40 sRelease builds bundle JavaScript by running Metro once: the Gradle plugin's bundle task on Android and the Xcode build phase on iOS both invoke the bundle command, and Expo projects bundle through `npx expo export:embed`. Metro spreads transformation over a pool of workers sized by `maxWorkers`, whose default derives from `os.availableParallelism()`, the core count Node reports. Metro also keeps a second pool for building the file map with the same count. Each worker holds its own Babel and module state, so memory scales with worker count. A runner that reports many cores but has a tight memory limit can be killed mid-bundle. Fixes: set `maxWorkers` in `metro.config.js`, or pass `--max-workers` to the bundle command, from Gradle via `extraPackagerArgs` or from Xcode via `EXTRA_PACKAGER_ARGS`. Measure: fewer workers is slower but predictable.
code
javascript · 9 lines// metro.config.js (React Native 0.87)
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
const ciWorkers = Number(process.env.METRO_CI_WORKERS);
module.exports = mergeConfig(getDefaultConfig(__dirname), {
// Only cap workers when the CI job sets the variable.
...(Number.isInteger(ciWorkers) && ciWorkers > 0 ? {maxWorkers: ciWorkers} : {}),
});go deeper
Recall that Metro bundles release builds too, and that it transforms files in parallel worker processes.
Explain maxWorkers' default from available cores, the two worker pools, and how to set it in config or with --max-workers.
Diagnose memory kills in CI bundling, cap workers through Gradle or Xcode packager arguments, and verify the effect by measuring time and peak memory.
Set runner sizing and bundler parallelism together as a build-capacity budget, trading pipeline speed against cost and flakiness across all app builds.
## The symptom A CI job that builds a React Native release fails in the JavaScript bundling step. The log stops mid-bundle, or the process exits with a signal that indicates it was killed for memory, while the same build works on a developer laptop. The cause is often not the app's code but **how many workers Metro started** on the CI machine. ## Where Metro runs in a release build A release build does not use the development server. Instead the native build runs Metro once in **bundling mode**: - **Android**: the React Native Gradle plugin's bundle task runs the bundle command and embeds the output; - **iOS**: the "Bundle React Native code and images" build phase runs `react-native-xcode.sh`, which calls the same command; - **Expo**: the native build calls `npx expo export:embed`, which accepts the same `--max-workers` flag, as does `npx expo export`. So Metro's normal configuration, including `maxWorkers`, applies inside the native build. ## How maxWorkers works - **`maxWorkers`** is a top-level Metro option: the number of workers used for parallel processing. - **Default**: derived from `os.availableParallelism()`. Metro's docs call it approximately half the cores, but the formula gives more on typical machines (6 workers on 8 cores) and approaches half only on very large ones. - **Two pools**: Metro has one pool for transformation and one for building the file map, and each is sized to `maxWorkers` independently. - **Upper bound**: values above the core count have no effect. - **1 or lower**: the work runs inside the main Metro process instead of concurrently. - **Processes or threads**: plain Metro uses child processes (`transformer.unstable_workerThreads` is `false`); Expo's default config turns worker threads on. Either way, every worker carries its own Babel and module state. Memory therefore grows roughly with the worker count. A runner that exposes many cores to Node but gives the job a modest memory limit is exactly the machine where the default is too high. ## Tuning it 1. **In the Metro config**, for every build: ```js module.exports = mergeConfig(getDefaultConfig(__dirname), { maxWorkers: 2, }); ``` 2. **On the command line**, for one step: `npx react-native bundle --max-workers 2 …`, `npx expo export:embed --max-workers 2 …` or `npx expo export --max-workers 2`. 3. **From the native build**: - Android: in the `react { }` block of `android/app/build.gradle`, `extraPackagerArgs = ["--max-workers", "2"]`; - iOS: set `EXTRA_PACKAGER_ARGS` in the build phase environment; the script appends it to the bundle command. 4. **Make it conditional** if only CI needs it, for example by reading an environment variable in `metro.config.js`, so laptops keep full parallelism. ## Judging the tradeoff | Setting | Effect | |---|---| | default | fastest on well-provisioned machines; can exhaust memory on tight runners | | lower `maxWorkers` | less peak memory, longer bundle step | | `maxWorkers: 1` | lowest memory, no parallelism; useful for bisecting a crash | Measure the bundle step's duration and peak memory at two or three values and pick the knee, rather than setting 1 everywhere. Also check the other memory lever, the Node heap limit (`--max-old-space-size` through `NODE_OPTIONS`), since a single large module can exhaust one worker's heap even with few workers. ## Related causes to rule out - **A huge generated file** (a large JSON dataset or a generated API client) can dominate memory in whichever worker gets it. - **A cold cache on CI** means every file is transformed from scratch, so a bundle step that is fine locally, where most results come from cache, does far more work on CI. - **Several builds sharing one machine**: two concurrent bundles each start their own worker pools. ## What a senior answer shows It connects the release build to Metro's bundle command, knows the default is derived from the core count and that there are two pools, names the three places to set the limit (config, CLI flag, native build arguments), and treats the change as a measured tradeoff rather than a magic number.
- How do you pass --max-workers to the bundle step of an Android release build?In `android/app/build.gradle`, the `react { }` block of the React Native Gradle plugin has `extraPackagerArgs`, a list of extra arguments for the bundle command. Setting it to `["--max-workers", "2"]` caps the pool for that build only.
- Why can the bundle step on CI need far more work than on a laptop?A fresh CI runner usually has no Metro transform cache, so every file in the graph is transformed from scratch, while a laptop reuses cached results. More transformation means more workers busy at once and a higher memory peak.
saying these in an interview costs you the question
- Release builds use the development server, so maxWorkers does not matter
- More workers always makes bundling faster and never costs memory
- maxWorkers only affects file transformation, not the file map
- Setting maxWorkers above the core count adds extra workers
- The only fix for bundling out of memory is a bigger runner