skip to content

In a React Native Hermes release build, why are two source maps composed into one, and what breaks if you skip it?

level: seniorimportance: nice to knowfreq 15%

answer

  1. two compile steps, two maps
  2. packager map: bundle to source
  3. compiler map: bytecode to bundle
  4. compose-source-maps.js chains them
  5. hermesFlags must keep -output-source-map

basics

~20 s

Metro's packager map links the bundle to your sources; hermesc's compiler map links bytecode to the bundle. Hermes crash frames are bytecode positions, so compose-source-maps.js chains the two; without that, frames resolve wrongly or not at all.

solid answer

~40 s

A Hermes release build transforms code twice: Metro bundles the modules and can write a packager map from bundle to source, then hermesc compiles the bundle to bytecode and, with `-output-source-map`, writes a compiler map from bytecode to bundle. Crash frames are bytecode positions, so neither map alone reaches your source; `compose-source-maps.js` chains them into one map from bytecode to source. The Android Gradle plugin does this automatically when `hermesFlags` include `-output-source-map`, writing the result to `generated/sourcemaps/react/<variant>/`; the iOS script does it when `SOURCEMAP_FILE` is set. Skip it, by dropping that flag or archiving the `*.packager.map`, and `metro-symbolicate` gives unresolved or plausible-but-wrong frames.

go deeper

for a junior

Know that a Hermes build compiles twice and that the map you use must cover both steps.

for a middle

Explain the packager map, the compiler map and the composed map, and which one each platform writes to its output location.

for a senior

Diagnose pipelines where composition silently didn't happen, from overridden hermesFlags to hand-rolled release scripts, and verify maps with a planted crash.

for a principal

Require an end-to-end symbolication check in the release process, since a silently wrong map misleads crash triage for every build it covers.

## Two transformations, two maps A Hermes release build transforms your code twice, and each step can describe its own transformation: 1. **Metro** turns hundreds of source modules into one JavaScript bundle. With `--sourcemap-output` it writes the **packager map**: bundle position to original file, line, column and name. 2. **hermesc** compiles that bundle to bytecode. With `-output-source-map` it writes the **compiler map**: bytecode position to bundle position. A Hermes crash frame reports a **bytecode** position. Neither map alone gets from there to your source: the compiler map stops at the bundle, and the packager map expects bundle positions, not bytecode ones. **Composition** chains them into one map from bytecode to source. | Map | From | To | Written by | |---|---|---|---| | Packager | JavaScript bundle | Your source files | Metro | | Compiler | Hermes bytecode | JavaScript bundle | hermesc | | Composed | Hermes bytecode | Your source files | `compose-source-maps.js` | ## Who composes, and when React Native ships the script as `react-native/scripts/compose-source-maps.js`. Its usage line is: ```bash node node_modules/react-native/scripts/compose-source-maps.js \ packager.map compiler.map -o composed.map ``` The standard builds call it for you: - **Android**: the Gradle plugin's Hermes bundling task writes the packager map to `intermediates/sourcemaps/react/<variant>/index.android.bundle.packager.map`, moves hermesc's output to `...compiler.map`, and composes them into `generated/sourcemaps/react/<variant>/index.android.bundle.map`, but only when `hermesFlags` contains `-output-source-map`. - **iOS**: `react-native-xcode.sh` does the same when `SOURCEMAP_FILE` is set and Hermes is in use, writes the result to `SOURCEMAP_FILE`, and deletes both intermediates. The composed map carries Hermes-specific data (function offsets) that `metro-symbolicate` reads when it resolves Hermes frames. ## What breaks if you skip it Composition goes missing in a few realistic ways: - A custom `hermesFlags` list in `build.gradle` without `-output-source-map`: the plugin skips hermesc's map and the composition, so `generated/sourcemaps` stays empty and only the packager map exists. - A hand-written release script, for example in a brownfield app or custom CI, that runs Metro and hermesc directly and keeps only Metro's map. - Someone grabbing the `*.packager.map` from `intermediates` because it looks like "the" source map. In each case the frames either fail to resolve or, worse, resolve to **plausible but wrong** lines, because bytecode offsets are being read as bundle positions. A wrong symbolication is more expensive than none: it sends the investigation into innocent code. ## Signs you are holding the wrong map - The symbolicated top frame lands in code that cannot throw the reported error, such as a style object or an import line. - Every frame resolves to a handful of the same library files regardless of the crash. - Frames stay unresolved even though the map is clearly from the right release. - The map's file name ends in `.packager.map` or `.compiler.map`, which are the intermediates on Android. Each of these points first at composition, and only then at a version mismatch. ## How to check a pipeline 1. Build a release variant with a deliberate JavaScript crash behind a hidden debug gesture or a test flag. 2. Capture the stack from `adb logcat` or the iOS device log. 3. Run it through `metro-symbolicate` with the map your pipeline archived. 4. Confirm the top frame names the file and line you planted. If step 4 fails, look at which map was archived before anything else. ## Where this sits The interview point is not the script's name but the model: **every compilation step needs a map, and the maps must be chained**. The same idea applies whenever a tool adds a stage between your source and what runs. The Hermes bytecode format itself, and how the engine executes it, is a separate topic.

  • How would you notice in a build that composition didn't happen on Android?
    The composed file at android/app/build/generated/sourcemaps/react/release/index.android.bundle.map would be missing, while intermediates/sourcemaps/react/release still holds a packager map. That pattern means hermesFlags lacks -output-source-map. A release pipeline should check that the composed file exists before archiving anything.
  • Does a JavaScriptCore build need composition?
    No. Without Hermes there is no bytecode compilation step, so Metro's map already describes what runs; the Android plugin writes Metro's map straight to the output location and the iOS script writes it straight to SOURCEMAP_FILE. JSC is now a community package rather than part of core, so the Hermes flow is the normal one.

It is a book translated twice, English to French to Japanese, with a page-number concordance kept for each translation. To find where a Japanese page came from in the English original you need both concordances chained; the English-to-French one alone points you at the wrong page.

saying these in an interview costs you the question

  • Metro's source map alone is enough for Hermes crashes.
  • hermesc's map points bytecode straight to your source files.
  • Composition only matters for iOS builds.
  • A wrong map fails loudly, so you'll notice at once.
  • Custom hermesFlags don't affect source map output.