Engineering

React Native OTA Source Maps with Sentry and Hermes

How to keep a React Native OTA Hermes bundle, its composed source map, and every Sentry event on one exact artifact identity.

12 min read

Written by Founder and maintainer of Bundle Drop

An error from a React Native over-the-air update is useful only if the error tracker can map the generated stack frames back to the source that produced them. With Hermes, a production stack may contain minified names and bytecode offsets. A source map is what turns those positions into readable files, functions, and lines.

The difficult part is not merely generating a map. Several OTA bundles can run under the same native app version, so 1.8.0 (142) does not identify the JavaScript that crashed. Reliable symbolication requires one exact relationship: the running Hermes bundle, its composed source map, and the Sentry event must carry the same immutable OTA artifact identity.

This guide explains that relationship and the failure modes around it. It is not a general Sentry installation tutorial or a replacement for the current Bundle Drop observability commands.

Why the native app version is not enough

A store build has a native version and build number. Once OTA delivery is enabled, that binary may execute its embedded JavaScript, update A, update B, or a previous bundle restored after recovery. Every one of those states can report the same native version.

Teams migrating a CodePush and Sentry source-map workflow face the same matching problem: the provider-specific update label may change, but the running JavaScript artifact still needs a stable identity.

If source maps are stored only under the app version, Sentry has no reliable way to distinguish the JavaScript artifacts. A map for update B may be valid JSON and still be wrong for an event from update A. The result is commonly an unsymbolicated stack, but a mismatched map can be worse: it may produce plausible-looking file names and lines that describe different code.

React Native's release-build debugging guide notes that small source changes can move offsets substantially and that symbolication needs the corresponding source map. For OTA releases, the practical boundary is stricter than “the same commit”: preserve the map produced alongside the exact deployed bundle, with the same bundler configuration, dependencies, environment inputs, platform, and Hermes compiler path.

The three records that must agree

One OTA identity connects build output to the crash event

Sentry can symbolicate the stack only when the uploaded bundle and composed source map describe the artifact that was actually executing.

Build once

Hermes bundle + composed source map

Generate and retain the production bundle and its map as one pair. Do not rebuild either file before upload.

Identify

Immutable OTA artifact identity

Carry the same update hash into source-map upload metadata and the runtime observability context.

Match in Sentry

Upload metadata = event metadata

The release and distribution on the event select the exact bundle and map needed for readable JavaScript frames.

RecordWhat it must preserveWhat a mismatch looks like
Build artifactThe production Hermes bundle, its exact composed source map, platform, and immutable update identityRebuilding the map later produces different generated positions or metadata
Source-map uploadThe exact bundle and map plus the release-matching fields Sentry will useFiles exist in Sentry, but under another release or distribution
Runtime eventThe identity of the bundle actually executing when the error occurredThe event points at the embedded bundle, a pending update, or another OTA release

These records should be created from one build, not reconstructed from memory during an incident. The source map is part of the release artifact even though users never execute it.

Build the Hermes bundle and source map as one artifact

Metro first transforms and bundles the JavaScript. Hermes then compiles that generated output, and the build pipeline composes the relevant mappings so bytecode positions can be traced back to the original source. React Native's Android build tooling describes the production task as invoking bundle, hermesc, and compose-source-map together in the React Native Gradle Plugin documentation.

That sequence has two operational consequences:

  1. Retain the final composed map, not whichever intermediate map is easiest to find.
  2. Upload the bundle and map created by the release job; do not rerun Metro or Hermes later to recreate them.

A source revision is useful provenance, but it is not a complete artifact identity. Dependency resolution, environment substitution, Metro configuration, minification, Sentry transforms, and Hermes output can change generated bytes without a new application commit. “Built from the same Git SHA” is therefore a useful check, not proof that two artifacts are interchangeable.

Bundle Drop's upload command can keep main.jsbundle, main.jsbundle.map, and bundle-drop-result.json together with --sourcemap and --artifact-dir. The result file records the returned update hash and artifact paths so the source-map step can consume the files that were actually published. The current commands belong in source-map artifact uploading and the OTA CI/CD workflow.

How Sentry matches React Native OTA source maps

Sentry supports more than one source-map matching mechanism. The important fields in an OTA workflow are:

  • Release: a broad application release identity, commonly tied to the native app release.
  • Distribution (dist): a subdivision of that release. Sentry's release-file API explicitly allows an uploaded file to be associated with a dist value.
  • Debug ID: an identifier injected into generated JavaScript and its source map by supported Sentry tooling so an event can refer directly to matching artifacts.

These mechanisms are not interchangeable labels. A pipeline should choose the matching contract supported by its Sentry integration and apply it consistently.

Bundle Drop's documented Sentry path uses the native application release as release and the immutable OTA update hash as dist. CI uploads the exact bundle and map under that pair. At runtime, the event reports the same release and obtains dist from getObservabilityContext(). Sentry exposes release, distribution, and Debug ID information when diagnosing source-map matching, as shown by its event source-map debug API.

Conceptually, the contract is small:

text
source-map upload: release = native release, dist = OTA update hash
runtime event:      release = native release, dist = active OTA update hash

The precise release string can follow your existing Sentry convention. Consistency matters more than inventing a new naming scheme for OTA.

Hermes makes exact pairing more important

Hermes production frames can contain bytecode offsets rather than positions in the original TypeScript or JavaScript. React Native documents source maps as required to translate those offsets into readable frames. If the wrong composed map is selected, the final step back through Hermes and Metro no longer describes the code that ran.

This is why a successful source-map upload is not sufficient evidence. The uploaded files must be the final bundle/map pair, and the event must select them. Platform artifacts also remain separate: an Android Hermes bundle and its map should not be treated as the source artifact for an iOS event merely because both came from the same application commit.

Source maps solve JavaScript symbolication. Native crashes still need the corresponding native symbols, such as iOS dSYMs or Android mapping and native debug files. A JavaScript map cannot turn a native instruction address into an Objective-C, Swift, Java, Kotlin, or C++ frame.

Debug IDs are useful, and they are build input

Modern Sentry tooling can inject a content-derived Debug ID into the generated bundle and source map. That is useful for automatic artifact matching. It also means the Sentry Metro integration participates in the bytes that proceed into Hermes.

For Hermes OTA updates that rely on binary patch delivery, changing generated bytes can affect patch size even when the application change is small. Bundle Drop therefore detects Sentry Debug ID markers in a Hermes OTA bundle and points to a documented OTA-specific Metro configuration.

The current Bundle Drop configuration is intentionally narrow: it has been validated with @sentry/react-native 7.8.x and skips Debug ID serialization only for the Bundle Drop OTA build subprocess while retaining Sentry's Babel transform, frame collapsing, source-map generation, and Hermes composition. It does not recommend removing Debug IDs from ordinary native builds, and it should not be copied unchanged to a newer Sentry major without checking that version's Metro integration. See Sentry and Hermes OTA builds for the maintained configuration and verification steps.

Report the bundle that is actually executing

OTA clients have more state than “downloaded” or “not downloaded.” Runtime observability must describe the bundle executing now, not the newest artifact known to the device.

Runtime stateIdentity to report
Embedded bundle, with no active OTA updatesource = embedded, dist = embedded
Active OTA updatesource = ota, dist = active update hash
New update downloaded but pending activationKeep reporting the currently active bundle; do not report the pending hash
Recovery restored an earlier bundleReport the restored bundle's active identity, or embedded if recovery returned there

Bundle Drop's getObservabilityContext() handles the embedded and pending cases: a bundle marked pendingApply is not yet executing, so it is not reported as the active OTA distribution. This distinction prevents events from the old bundle being attributed to the candidate merely because the candidate has reached disk.

The same rule applies if runtime context is implemented without Bundle Drop: derive observability identity from the active bundle resolver, not from “latest release,” “last downloaded update,” or a value cached before activation.

A stable CI-to-runtime contract

A durable source-map workflow can be reviewed as six steps:

  1. Build once. Generate the production platform bundle and final composed source map together with the same Metro, Sentry, and Hermes configuration used for OTA publication.
  2. Retain the evidence. Store the bundle, map, output metadata, source revision, dependency lock state, platform, and runtime version as one CI artifact.
  3. Publish that artifact. Obtain the immutable OTA update identity without rebuilding the bundle.
  4. Upload the exact pair to Sentry. Use the same release value expected at runtime and the OTA identity as the distribution in the documented Bundle Drop path. Sentry's release-file API associates uploaded files with a dist value in its release file documentation.
  5. Initialize runtime metadata from active state. Attach the active update hash, channel, runtime version, platform, and source to error events.
  6. Keep maps private. Source maps can contain original paths and sourcesContent. Upload them directly from authenticated CI rather than exposing them beside the public bundle.

Do not allow the Sentry upload to become an unobserved best-effort side effect. If production debugging depends on symbolication, failure to retain or upload the matching map should fail the release or stop its promotion according to an explicit policy.

Verify the path before production

The best test is a real event from a staged release build, not a successful CLI exit code.

  1. Publish an OTA update to a non-production channel with source maps and retained artifacts enabled.
  2. Install and activate that exact update on a release build using Hermes.
  3. Confirm the runtime context reports the expected active update hash rather than embedded or a pending hash.
  4. Trigger an intentional JavaScript error from a known file and line.
  5. In Sentry, verify the event's release and distribution, then confirm the top application frames resolve to that known source.
  6. Repeat after a second OTA release so two bundles under the same native version remain distinguishable.

Expo recommends the same practical style of verification for EAS Update: upload maps after the update and throw an intentional error to confirm the integration in its Sentry guide. The provider-specific commands differ; the need to test the deployed artifact does not.

Troubleshooting unsymbolicated OTA errors

SymptomLikely causeFirst check
Event arrives but JavaScript frames stay minifiedThe bundle/map pair was not uploaded, upload failed, or the event cannot select itCompare the event's release and dist with the source-map upload record
Frames resolve to surprising files or linesA map was rebuilt or taken from another updateCompare retained bundle and map checksums with the published artifacts
Release matches but dist does notRuntime reported another active state, or CI used a different update hashInspect the event metadata and bundle-drop-result.json from the release job
Events show the pending candidate before activationObservability reads downloaded state rather than executing stateDerive identity from the active bundle resolver
Only one platform symbolicatesAndroid and iOS artifacts or upload steps were crossed or configured differentlyCheck platform-specific bundle paths, composed maps, and upload logs
A native crash remains unreadableJavaScript source maps are not native symbolsVerify dSYM, ProGuard/R8 mapping, and native debug-file upload separately

Keep the original event and release artifacts while investigating. Re-running the build may help reproduce a configuration problem, but it cannot manufacture proof that a newly generated map matches an already deployed bundle.

What source maps do not solve

Readable frames improve diagnosis; they do not prove that an OTA update caused the error. Compare the event's active update identity with adoption, startup health, and release timing before drawing that conclusion. A rollout system still needs its own stop conditions and recovery path, covered in React Native OTA Updates: How Production Delivery Works.

Source maps also answer a different question from bundle analysis. Crash symbolication asks which original source position produced a runtime frame. Bundle analysis asks which modules account for generated bytes. Both require an exact bundle/map pair, but they should use separate production workflows. How to Analyze a React Native Bundle with Source Maps covers the size-attribution path.

Make artifact identity part of the release

React Native OTA source maps are dependable when they are treated as release artifacts rather than files recreated during debugging. Build the Hermes bundle and final composed map once, retain them together, upload that exact pair under a stable Sentry contract, and report the identity of the bundle actually executing.

With that chain intact, several OTA releases can share one native app version without becoming indistinguishable in production. When it breaks, the right question is not simply “did CI generate a source map?” It is “does this event select the exact map for these running bytes?”

About the author

Founder and maintainer of Bundle Drop

George builds Bundle Drop and writes about the compatibility, delivery, rollout, and recovery boundaries behind React Native OTA updates.

GitHub profile

Interested in safer OTA deployments?

Read the implementation guides or connect a React Native project when you are ready to test the release workflow.