DEV Community

Robin for HotCodePush

Posted on

Which React Native, Expo and Capacitor changes need an app store release? An OTA update checklist

An over-the-air (OTA) update replaces the JavaScript or web layer of an installed app. It never replaces the native layer. So the rule is short: a change needs an app store release when it touches native code, native dependencies, native configuration or native assets. Everything that lives in your JavaScript bundle or your web build can ship as an OTA update.

The hard part is not the rule. The hard part is knowing which of your everyday changes fall on which side, and catching the native ones before you publish. This post gives you a table for React Native, Expo and Capacitor, the detection tools each ecosystem offers, the exact store policy text, and what happens when you get it wrong.

Which changes can ship as an OTA update, and which need the store?

Change React Native Expo Capacitor Verdict
JavaScript or TypeScript logic bundle bundle web build OTA update
Styles, strings, translations bundle bundle web build OTA update
Images and fonts loaded from JavaScript or HTML bundle assets update assets web build OTA update
Remote config values, feature flags, API endpoints bundle bundle web build OTA update
Bumping a pure JavaScript npm package bundle bundle web build OTA update
Bumping an npm package that contains native code, even a patch release native native native store release
Adding or removing a native module or Capacitor plugin native native native store release
Upgrading React Native, the Expo SDK or Capacitor native native native store release
Permissions and usage strings (Info.plist, AndroidManifest.xml) native app config native store release
App icon, splash screen, app name, bundle identifier native app config native store release
Fonts linked into the native project native config plugin not applicable store release
Changes to capacitor.config.ts or capacitor.config.json not applicable not applicable copied into the app at npx cap sync store release
Changes to app.json plugins or native options not applicable prebuild not applicable store release
Your own Swift, Kotlin, Objective-C or Java code native native native store release

Two rows surprise most teams.

A patch release of a dependency with native code is a native change. If react-native-reanimated or @capacitor/camera moves from 4.1.2 to 4.1.3, its native code may have changed. The JavaScript side of your bundle now expects the new native side. A device still running the old binary has the old native code. Treat every version bump of a package that ships native code as a store release, unless you have checked that only its JavaScript changed.

Capacitor configuration is native. npx cap sync copies the web build into the native projects and also copies your Capacitor configuration into the iOS and Android app. Capacitor's CLI reference describes cap copy as the command to "copy the web app build and Capacitor configuration file into the native platform project" (capacitorjs.com, retrieved 2026-11-07). A changed server.hostname, plugins block or appId therefore rides with the binary, not with the web assets.

Why does a JavaScript engine version matter?

Hermes compiles your JavaScript to bytecode at build time. Expo's Hermes guide states it plainly: "an update produced for a specific version of Hermes will not run on a different version of Hermes" (docs.expo.dev, retrieved 2026-11-07). Hermes ships with React Native, so a React Native upgrade changes the Hermes version, and a bundle built for the new version cannot run inside the old binary.

That is why "upgrading React Native" sits in the store column even when you changed no native file yourself. The same holds for an Expo SDK upgrade, which carries a React Native upgrade with it. Expo SDK 58, released on 2026-10-15, ships React Native 0.88.

How do you detect a native change before you publish?

Reading the table is not enough when a pull request bumps forty packages. You want a tool to tell you whether the native layer moved. Each ecosystem has one.

Expo: runtimeVersion with the fingerprint policy

Expo's runtimeVersion is "a property indicating compatibility between a build's native code and an OTA update" (Expo config reference, retrieved 2026-11-07). With the fingerprint policy, the @expo/fingerprint package computes a hash "derived from hashing app dependencies, custom native code, native project files, and configuration". A build and an update with different fingerprints are never paired.

{
  "expo": {
    "runtimeVersion": { "policy": "fingerprint" }
  }
}
Enter fullscreen mode Exit fullscreen mode

The other policies, appVersion, nativeVersion and sdkVersion, tie compatibility to a version number you manage by hand. They work, but a forgotten bump is the classic way an incompatible update reaches a device.

React Native and Capacitor: a fingerprint of the native contract

HotCodePush, the OTA update service for React Native, Expo, Capacitor and Cordova, uses the same idea and calls it a fingerprint: a hash of the project's native dependencies, stamped into every build and computed for every bundle at upload. It reads the committed lockfile and hashes the packages that carry native code, the runtime packages and any extra files you list. A pure JavaScript change does not move it. A plugin added, removed or bumped moves it, a patch release included.

You can print it locally without an account:

npx hotcodepush fingerprint
npx hotcodepush fingerprint diff store-build.json current.json
Enter fullscreen mode Exit fullscreen mode

The first command lists every package and file that contributes to the hash. The second compares two outputs and names what changed. Run it in a pull request check and the question "does this PR need a store release?" gets a mechanical answer. The full rules live in the compatibility concept.

Capacitor without a tool: watch the diff of npx cap sync

If you use no fingerprint, the next best signal is your version control. Run npx cap sync and look at the diff under ios/ and android/. A change to Podfile.lock, capacitor.build.gradle, capacitor.settings.gradle or the copied capacitor.config.json means the native layer moved. No diff there means the web build alone changed.

What do Apple and Google allow an OTA update to change?

Both stores allow interpreted code, with conditions. The exact text matters, so here it is.

Apple's Developer Program License Agreement, section 3.3.1(B), last updated 2026-10-29: "Interpreted code may be downloaded to an Application but only so long as such code: (a) does not change the primary purpose of the Application by providing features or functionality that are inconsistent with the intended and advertised purpose of the Application (b) does not bypass signing, sandbox, or other security features of the OS; and (c) for Applications distributed on the App Store, does not create a store or storefront for other Applications."

Google Play's Device and Network Abuse policy (retrieved 2026-11-07): "An app distributed via Google Play may not modify, replace, or update itself using any method other than Google Play's update mechanism. Likewise, an app may not download executable code (such as dex, JAR, .so files) from a source other than Google Play. This restriction does not apply to code that runs in a virtual machine or an interpreter where either provides indirect access to Android APIs (such as JavaScript in a webview or browser)."

Read together, the policies draw the same line as the table above. JavaScript that fixes bugs, changes screens or adjusts flows within the app's advertised purpose is allowed. Executable native code is not, and neither is a new product hiding inside an old one.

What happens when an OTA update reaches an incompatible binary?

The failure shows up at launch, not at upload. The JavaScript calls a native module that does not exist in the binary, or a Hermes bytecode version the runtime cannot read. The user sees a crash or a red error screen, every time they open the app, until a new store build arrives or the update tool rolls back.

A compatibility rule prevents this before it starts. With Expo's runtimeVersion, an update with a different runtime version is never served to that build. With a fingerprint, a device whose binary carries another fingerprint skips the release and keeps running what it has. HotCodePush reports that skip as device_incompatible, shows the count per release in the console, and keeps serving each older store build the newest release built for it.

The practical consequence is that a native change does not block OTA updates for everyone. It blocks them for the binaries that lack the native change. Devices on the old binary keep getting the fixes built for them. Devices that install the new store build pick up the new line. The two lines live side by side until the old binary has no devices left.

A rule of thumb for code review

Ask one question per pull request: did anything under the native layer move? Native code, native dependencies, native configuration, native assets or the JavaScript engine. If yes, the change needs a store release, and your OTA tool should refuse to pair the bundle with old binaries. If no, ship it over the air.

Automate the question with a fingerprint, in Expo's config or in your OTA tool's CLI, and you will not need to remember this table at all.

If you want to see how the fingerprint pairs releases with store builds in practice, the HotCodePush quickstart takes about ten minutes with a React Native, Expo or Capacitor app. Corrections to the table are welcome in the comments.

Top comments (0)