Engineering

Runtime versions: the compatibility boundary for React Native OTA updates

A technical model of per-platform runtimeVersion lanes, literal and Expo authority, channel separation, and coexistence across native binaries.

8 min read

A React Native application is not one artifact. The store binary contains native code, native dependencies, configuration, a JavaScript engine, and an embedded JavaScript bundle. An OTA release replaces only the JavaScript bundle and its bundled assets. It must therefore run against native capabilities that are already present on the device.

runtimeVersion names that compatibility boundary. A device may load an OTA bundle only when the device and bundle have the same platform runtime version. The value is not a marketing version and does not describe which release is newer. It answers one narrow question: can this JavaScript and asset set safely execute inside this native binary?

Compatibility is resolved on each platform runtime line

A channel can contain several runtime lines. The installed binary only receives a bundle built for its exact platform runtime.
iOS runtime line

Installed binary

ios: 2.3

Eligible bundle

ios: 2.3

Excluded bundle

ios: 2.4

Android runtime line

Installed binary

android: 5.1

Eligible bundle

android: 5.1

Excluded bundle

android: 5.2

Think in compatibility lines

Suppose version 4.2 of an app is installed on both platforms with these Bundle Drop runtime values:

js
module.exports = {
  runtimeVersion: {
    ios: "4.2-native-1",
    android: "4.2-native-1",
  },
};

A copy change, a TypeScript bug fix, or a new screen built entirely from existing native components can remain on that line. Uploads for ios and android record the applicable literal value, and binaries on that platform can resolve them.

Now add an iOS-only native module. JavaScript using the new module is incompatible with the installed iOS binary, but Android has not changed. Bump only the iOS runtime, build and distribute a new iOS binary, and keep Android on its current line:

js
module.exports = {
  runtimeVersion: {
    ios: "4.2-native-2",
    android: "4.2-native-1",
  },
};

The result is two independent lanes. Older iOS binaries continue to resolve the last compatible update for 4.2-native-1; new iOS binaries resolve updates for 4.2-native-2. Android continues on 4.2-native-1. No channel reset is required.

This per-platform model matters because compatibility changes are often asymmetric. Treating every iOS native change as an Android runtime change creates unnecessary fragmentation and forces releases that carry no Android safety benefit.

Know when to preserve the runtime

Keep the existing runtime value when the update uses only capabilities already present in every binary on that line. This commonly covers JavaScript or TypeScript logic, copy and styling, navigation built from existing components, assets supported by current loaders, calls to unchanged native interfaces, and dependencies that affect only JavaScript output.

“JavaScript-only” is useful shorthand, but it is not a proof. A package can have both JavaScript and native parts. A configuration edit can alter generated native files. A Babel or Metro change may be safe while a Hermes, JSC, or React Native architecture change is not. Review the resulting native build inputs rather than the filename of the change.

Know when to create a new line

Bump the affected platform runtime and create a new store binary when an OTA bundle could depend on native state that old binaries do not have. Representative triggers include native-module or compiled-code changes, permissions or generated native configuration, a different JavaScript engine or React Native architecture, and Expo config-plugin output that changes the native projects.

When uncertain, compare clean native generations or builds before and after the change. A broader runtime boundary may cause more users to wait for a store update, but an incorrectly broad boundary can send JavaScript to a binary that cannot execute it. Compatibility should fail closed.

If the change itself is ambiguous, use the OTA-or-store decision framework to inspect dependencies, generated native configuration, assets, and platform requirements before choosing the runtime line. The configuration reference lives in Runtime Version.

Runtime version is different from channel

A channel answers which release stream does this installation follow? A runtime version answers which native interface can this bundle use? They are separate axes.

One production channel can contain iOS and Android updates for several runtime lines. When an installation checks that channel, Bundle Drop narrows candidates by project, channel, platform, and exact runtime version before applying publishing and rollout rules. A bundle on the same channel but a different runtime is not a fallback; it is incompatible.

This separation enables a practical store rollout:

  1. Existing binaries continue following production on runtime A.
  2. A new binary is released gradually through the stores on runtime B.
  3. Compatible OTA fixes can still be published for runtime A while adoption grows.
  4. New features that require the native change are published only for runtime B.

Do not encode audience, environment, or release-stage meaning into the runtime value. Use channels for release tracks and rollout controls for audience eligibility. Runtime values should change only when compatibility changes.

Release Channels vs Environments maps channel, runtime version, app-defined environment, and Bundle Drop infrastructure environment as four independent axes.

Literal authority is the default

Bundle Drop uses explicit literal values from bundle.drop.config.js by default for both Expo and bare React Native projects. Your team owns the compatibility decision:

js
module.exports = {
  projectType: "expo",
  runtimeVersion: {
    ios: "1.0.0",
    android: "1.0.0",
  },
};

The upload command resolves the platform value from the committed configuration. It does not accept a flag that silently substitutes a missing runtime, and it fails when no runtime can be resolved. This keeps local and CI uploads on the same declared boundary.

Literal authority is intentionally independent of Expo’s runtime policies. It does not require an Expo build receipt or compare a fingerprint during upload. That makes the workflow consistent across Expo and bare projects, but it also makes the team responsible for bumping the value at the right time.

Commit the config, review runtime changes like native code, and build the native app from the same revision before publishing updates to a new line.

Strict Expo build identity is optional

Expo’s own update system supports manual runtime strings and derived policies such as appVersion, nativeVersion, and fingerprint. The Expo Updates API reference describes how a remote update and binary must share a runtime value; its fingerprint policy derives that value from project inputs.

Bundle Drop can optionally delegate runtime authority to the evaluated Expo configuration:

js
module.exports = {
  projectType: "expo",
  runtimeVersion: { source: "expo" },
};

This is strict Expo authority, not an alias for the default literal mode. It binds an upload to the exact Expo build identity, requires a matching local or imported EAS build receipt, and rejects the upload when the resolved identity differs. When native directories are committed, the strict layered-prebuild path also requires a clean worktree so generated native state can be verified without mixing unrelated edits.

Choose strict mode when proof of the exact evaluated Expo build is part of the release policy. Choose literal values when the team deliberately owns compatibility and wants the same boundary model across Expo and bare React Native. Neither choice removes the need to understand native changes; they place authority and enforcement in different parts of the workflow.

See Expo support for receipt handling and EAS Build details.

Separate runtime identity from app version

App-store version, build number, bundle version, and runtime version solve different problems:

ValuePrimary purpose
App versionUser-visible native release identity
iOS build number / Android version codeStore submission ordering
Bundle Drop bundle versionOrder uploaded bundles inside a project
Runtime versionNative-to-JavaScript compatibility
Bundle hashIdentify one exact OTA artifact

It is valid to use an app version string as a runtime convention if every native compatibility change also advances that app version. It is not inherently required. A team may keep a runtime stable across several store builds whose native interface remains compatible, or create a new runtime within the same user-visible version when a native build changes.

The important property is consistency: the binary and its OTA bundle must resolve the same value, and the value must not group incompatible native interfaces.

Design the release workflow around old binaries

Mobile adoption is not instantaneous. At any time, production can contain multiple native versions, runtime lines, and OTA bundle hashes. A sound workflow assumes that older compatible binaries will remain active.

Before publishing, answer:

  1. Which platform is this bundle for?
  2. Which installed native interface does it require?
  3. Is that interface represented by the configured runtime value?
  4. Has a matching native binary actually been built and distributed?
  5. Does the chosen channel contain an eligible release for users still on the previous line?

The normal upload flow records the resolved runtime with the bundle. Delivery then selects within that platform/runtime lane. If a device requests a runtime for which no compatible bundle exists, the safe result is no update—not a newer-looking bundle from another line.

Treat runtime changes as release-engineering changes

Runtime versions work because they turn a difficult compatibility judgment into a small, enforceable interface. They do not make the judgment automatically in literal mode.

Keep values platform-specific, bump them only for native incompatibility, and retain the old line while old binaries remain in use. Use channels for release tracks, bundle hashes for exact artifact identity, and strict Expo authority only when you want build-derived identity enforcement. With those roles separated, OTA delivery remains predictable even while several generations of the native app coexist in production.

Interested in safer OTA deployments?

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