Fixing React Native Over-the-Air Updates That Cause Version Mismatch Crashes
An over-the-air JS update ships without a new App Store release, and a subset of users start crashing — specifically the ones on an older native binary the new bundle quietly assumes has capabilities it doesn't. OTA updates replace the JavaScript; they don't replace what's actually compiled into the app on a given device.
The Problem
An app using an over-the-air JavaScript update mechanism — CodePush, Expo Updates — pushes a new JS bundle without going through a new native app store release. Shortly after, a subset of users start crashing on launch or hitting a specific broken feature, while others are completely unaffected. The pattern often correlates with users on an older native binary version, or with a partially completed update, rather than affecting everyone who received the push.
Why It Happens
OTA updates replace the JS bundle, not the native binary underneath it
If the new JS code calls a native module, a native API, or relies on a native dependency version that only exists starting from a native binary released after some users installed the app, the JS bundle is assuming a native surface that genuinely isn't present on their device. The call fails or crashes specifically for users on the older native binary, while working correctly for anyone who's also updated the native app itself — two entirely separate release channels that a single OTA push doesn't account for by default.
OTA rollouts commonly target "everyone" rather than a specific native version range
Without explicit segmentation, an OTA update is typically pushed to all users regardless of which native binary version they're actually running. If the JS change genuinely requires a native capability introduced in a more recent binary, pushing it broadly creates a real, findable version-skew crash for exactly the users who received the OTA update but never updated the native app — a gap in the rollout plan, not a bug in the JS code itself.
A partial or corrupted download can leave the app in an inconsistent state
An interrupted download or insufficient device storage can leave an OTA update partially applied. Whether this surfaces as a crash or goes completely unnoticed depends entirely on how gracefully the app's own update integration handles a failed or incomplete download — falling back cleanly to the last known-good bundle versus attempting to run a corrupted one.
OTA has real platform policy boundaries, not just technical ones
App store review guidelines — particularly Apple's — place genuine constraints on what an OTA mechanism is allowed to change. Treating OTA purely as a way to skip store review for changes it was never meant to cover (anything touching native-adjacent behavior, not strictly JS logic or UI) is a policy risk layered on top of the technical version-skew risk already described.
The Fix
1. Gate native-capability-dependent JS changes behind an explicit minimum native version check
import { nativeApplicationVersion } from "expo-application";
if (isAtLeastVersion(nativeApplicationVersion, "2.4.0")) {
useNewNativeCapability();
} else {
useLegacyFallback();
}
Never assume every user receiving an OTA update is running the same native binary — checking the actual installed native version before calling into a capability that depends on it lets the app degrade gracefully instead of crashing outright for users who haven't updated the native app.
2. Target the OTA rollout at the correct native version range explicitly
Configure the update mechanism's rollout to the native binary version range that actually supports the JS change, rather than defaulting to "all users" — treat "which native version does this specific JS change require" as a question that must be answered before every OTA push, not discovered afterward through crash reports.
3. Verify the app falls back cleanly on a failed or partial OTA download
Confirm that an interrupted or corrupted update download results in the app reverting to the last known-good bundle rather than attempting to run an incomplete one — this fallback path needs its own explicit testing, since it's exactly the scenario that a normal, successful-download test pass never exercises.
4. Keep OTA scoped to genuinely JS-only changes and respect platform policy
Reserve OTA updates for changes that are actually JS logic or UI adjustments with no native dependency, and route anything touching native-adjacent behavior through a real app store release instead. This avoids both the version-skew crash risk and the platform policy risk of using OTA for changes it isn't meant to cover.
Why This Works
Each fix addresses the same underlying gap: OTA updates and native binary releases are separate channels that don't automatically stay in sync for every user. Checking the actual native version before depending on a capability prevents the crash directly rather than assuming uniformity; explicit rollout targeting closes the segmentation gap that lets a version-skew crash reach users it was never meant to; verified fallback behavior handles the partial-download case rather than leaving it to chance; and respecting OTA's actual scope avoids reintroducing the same problem by routing native-dependent changes through the wrong channel.
Conclusion
Crashes appearing for a subset of users right after an OTA update almost always trace back to a JS bundle assuming native capabilities that a specific slice of the user base's native binary doesn't actually have — two separate release channels drifting out of sync, not a bug in the pushed JS itself. Gate native-dependent code behind an explicit version check, target OTA rollouts at the correct native version range rather than everyone, verify graceful fallback on a failed or partial download, and keep OTA scoped to genuinely JS-only changes that respect the platform's actual policy boundaries.
