TL;DR
- An OTA update swaps your JavaScript bundle and assets. It cannot touch native code. If you added a native module, changed a permission, or bumped a native dependency, that's a store release — no exceptions.
- CodePush is gone. Microsoft retired App Center (and hosted CodePush with it) on March 31, 2025. The React Native defaults now are EAS Update (hosted) or hot-updater (self-hosted).
- The thing that actually breaks production is runtime version mismatch: a JS bundle that expects native code the installed binary doesn't have. Use the
fingerprintruntime policy and you mostly stop thinking about it. - Never publish straight to 100%. Roll out to 10%, watch crash rates, then widen. Keep
eas update:rollbackone command away. - Yes, Apple and Google both allow this — with conditions. Don't push a feature a reviewer hasn't seen.
What an OTA update actually is
Your React Native app binary is two things glued together: a native shell (compiled Swift/Kotlin, native modules, permissions, entitlements) and a JS bundle plus assets that the shell loads at startup.
OTA works because the shell can be told to load a different bundle than the one it shipped with. That's it. That's the whole trick.
Which means the boundary is hard and non-negotiable:
| Change | OTA? |
|---|---|
| Fix a bug in a component | ✅ |
| Change copy, colors, layout | ✅ |
| Swap an image or font asset | ✅ |
| Update a JS-only dependency | ✅ |
Add react-native-camera or any native module |
❌ |
Bump expo-sqlite to a version with native changes |
❌ |
Add a permission to Info.plist / AndroidManifest.xml
|
❌ |
| Upgrade React Native or Expo SDK | ❌ |
If you ever find yourself asking "can I OTA this?", the honest answer is: run npx expo-doctor or diff your native fingerprint (more on that below). Don't guess.
Is this even allowed?
Short version: yes, both stores permit it, and both have a clause you need to respect.
Apple — the Developer Program License Agreement has an interpreted-code clause (§3.3.1(B) in the current numbering) that permits downloading scripts and code run by WebKit or JavaScriptCore, provided the update doesn't change the app's primary purpose, doesn't create a storefront for other code, and doesn't bypass sandboxing or signing. React Native's JS runs through JavaScriptCore/Hermes, which is what makes this legal.
Google Play — the Device and Network Abuse policy bans apps from updating themselves outside Play, except code running in a virtual machine or interpreter with limited Android API access. JavaScript in a sandboxed runtime is the textbook example.
My opinionated reading: OTA is for fixes and iterations on what the reviewer already approved. Shipping a whole new paid feature over the air that App Review never saw is the kind of thing that gets you a 3.3.1 rejection on your next submission, when they're grumpy. Don't.
The landscape in 2026 (post-CodePush)
If you learned React Native before 2025, your muscle memory says appcenter codepush release-react. That endpoint is dead. If your app still points at it, your users have silently stopped receiving updates. Check.
What's left:
- EAS Update — Expo's hosted service. Works with bare React Native too, not just managed Expo. Channels, branches, rollouts, rollbacks, bundle diffing. This is the default for most teams now.
- hot-updater — open source, self-hostable, plugin-based (S3, Cloudflare R2 + D1, Supabase, etc.). Pick this if "our bundles must live in our own bucket" is a hard requirement.
- Hosted CodePush forks — Bitrise, Codemagic, and a few others run services built on the original CodePush server and keep the old SDK working. Lowest migration cost if you have a lot of CodePush-shaped tooling.
-
Microsoft's standalone
code-push-server— released so you can self-host the original. Works, but you're now maintaining it.
The rest of this post uses EAS Update because it's what you'll hit first. The concepts transfer.
Setup
npx expo install expo-updates
eas update:configure
eas update:configure writes the updates.url and a runtimeVersion into your app config, and adds channels to your build profiles. The result looks like this:
// app.json
{
"expo": {
"runtimeVersion": {
"policy": "fingerprint"
},
"updates": {
"url": "https://u.expo.dev/<your-project-id>",
"checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0
}
}
}
// eas.json
{
"build": {
"preview": {
"distribution": "internal",
"channel": "preview"
},
"production": {
"channel": "production"
}
}
}
The channel is baked into the binary at build time. A build on the production channel will only ever pull updates published to a branch that production points to. You can't change a shipped binary's channel from the server side — so get this right before your first store submission, not after.
If you haven't done that first submission yet and the certificate / provisioning / screenshot / "12 testers on Play" gauntlet is what's actually blocking you, RapidNative Deploy handles the whole thing for React Native and Expo apps, including wiring EAS Build, Submit, and OTA channels. Worth knowing it exists if you're a solo dev staring at Xcode signing errors at 2am.
Runtime versions: the part that bricks apps
This is the bit everyone skips and then regrets.
A runtime version is a string that says "this binary has this native layer." EAS will only serve an update to a device if the update's runtime version exactly matches the binary's. Mismatch = the device ignores the update. Match-when-it-shouldn't = the device loads JS that calls native code it doesn't have, and crashes on launch.
You have three policies:
// Option 1: tie it to app version — simple, coarse
{ "runtimeVersion": { "policy": "appVersion" } }
// Option 2: hand-managed string — total control, total responsibility
{ "runtimeVersion": "2.4-native" }
// Option 3: fingerprint — hash of your actual native project
{ "runtimeVersion": { "policy": "fingerprint" } }
Use fingerprint. It runs @expo/fingerprint over your native directories, config plugins, and native dependencies, and produces a hash. If anything native changed, the hash changes, the runtime version changes, and your OTA update simply won't be served to old binaries. You physically cannot ship an incompatible bundle by accident.
Want to see what the fingerprint thinks changed between two commits?
npx @expo/fingerprint fingerprint:generate > fp-before.json
# ... make your changes ...
npx @expo/fingerprint fingerprint:generate > fp-after.json
npx @expo/fingerprint fingerprint:diff fp-before.json fp-after.json
If the diff is empty, it's OTA-safe. If it isn't, build a new binary.
The appVersion policy is fine for tiny apps, but it fails silently the moment someone adds a native dep in a patch release without bumping the version. I've watched that happen. Twice.
Checking for updates in-app
By default, expo-updates checks on launch and applies the new bundle on the next launch. With fallbackToCacheTimeout: 0 it never blocks startup waiting for a download, which is what you want — a cold start that hangs on a flaky network is worse than being one update behind.
But you'll usually want some control. The useUpdates hook gives you the state machine:
import { useEffect } from "react";
import { AppState } from "react-native";
import * as Updates from "expo-updates";
export function useOtaUpdates() {
const {
currentlyRunning,
isUpdateAvailable,
isUpdatePending,
} = Updates.useUpdates();
// Re-check when the app comes back to the foreground
useEffect(() => {
if (__DEV__) return; // expo-updates is disabled in dev
const sub = AppState.addEventListener("change", (state) => {
if (state === "active") {
Updates.checkForUpdateAsync().catch(() => {});
}
});
return () => sub.remove();
}, []);
// Download as soon as we know one exists
useEffect(() => {
if (isUpdateAvailable) {
Updates.fetchUpdateAsync().catch(() => {});
}
}, [isUpdateAvailable]);
return {
isUpdatePending,
updateId: currentlyRunning.updateId,
isEmbedded: currentlyRunning.isEmbeddedLaunch,
apply: () => Updates.reloadAsync(),
};
}
Then decide where to apply it. My rule: never reloadAsync() in the middle of user input. Apply on a natural boundary — after login, on returning from background, on a "Restart to update" banner the user taps.
function UpdateBanner() {
const { isUpdatePending, apply } = useOtaUpdates();
if (!isUpdatePending) return null;
return (
<Pressable onPress={apply} style={styles.banner}>
<Text>A new version is ready. Tap to restart.</Text>
</Pressable>
);
}
One more thing worth logging to your crash reporter on every launch:
Sentry.setContext("ota", {
updateId: Updates.updateId,
channel: Updates.channel,
runtimeVersion: Updates.runtimeVersion,
isEmbedded: Updates.isEmbeddedLaunch,
});
Without this, "crash rate went up" and "which update caused it" are two separate investigations. With it, they're one filter.
Shipping: publish → roll out → watch → widen (or roll back)
The naive flow is eas update --channel production and pray. Don't do that. Here's the flow that doesn't wake you up at night:
# 1. Publish to 10% of users on production
eas update --channel production \
--message "fix: checkout total rounding (#412)" \
--rollout-percentage=10
# 2. Watch crash-free rate for a few hours
# 3. Widen
eas update:edit # interactive: bump the rollout to 50, then 100
# 4a. Something's wrong? Revert the rollout — users go back to the previous update
eas update:revert-update-rollout
# 4b. Already at 100% and it's bad? Roll the whole branch back
eas update:rollback # choose "previous update" or "embedded bundle"
Two things people get wrong here:
Rollback to embedded vs. rollback to previous. Rolling back to the embedded bundle means every user goes back to whatever JS shipped inside their store binary — which, for users on an old build, could be months stale. Rolling back to the previous update is almost always what you want.
Rollout percentage is sticky per device. A user who's in the 10% stays in the 10%. So when you widen to 50%, you're adding new users, not re-rolling the dice. That's the behavior you want for debugging — it just surprises people.
If you'd rather roll out by branch (e.g. ship a whole QA'd branch to a slice of production), there's eas channel:rollout, but you can't publish with --channel while one is in progress, so pick one model per release.
Self-hosting with hot-updater
If EAS is a non-starter for you (compliance, cost at scale, "no third parties get our bundles"), hot-updater is the open-source option that's actually maintained. The shape of the config:
// hot-updater.config.ts
import { defineConfig } from "hot-updater";
import { metro } from "@hot-updater/metro";
import { d1Database, r2Storage } from "@hot-updater/cloudflare";
export default defineConfig({
build: metro({ /* bundler options */ }),
storage: r2Storage({ /* bucket, credentials */ }),
database: d1Database({ /* d1 binding */ }),
});
Swap the Cloudflare pair for @hot-updater/supabase or @hot-updater/aws depending on where you live. It has a web console, channels, semver-based targeting, and New Architecture support. The tradeoff is the obvious one: you own the uptime of your update server, and "update server is down" degrades to "users get the embedded bundle" — which is survivable, but it's your pager.
Rules I actually follow
- Fingerprint runtime policy, always. Remove the possibility of a native/JS mismatch instead of trying to remember.
-
Tag every update with the PR number in
--message. Future-you doingeas update:listat 1am will be grateful. - 10% → wait → 100%. No exceptions for "it's just a copy change." Copy changes have broken layouts in languages you didn't test.
-
Log
updateId+channelto your crash reporter. Otherwise rollbacks are guesswork. -
Apply on boundaries, never mid-interaction.
reloadAsync()during a form fill is data loss. - OTA is for what the reviewer already saw. New features, new monetization, new permissions → store release.
-
Test the update path itself. Install a store build, publish an update to a
previewchannel, confirm it arrives and the app relaunches cleanly. Do this once per native release, not once per lifetime.
OTA updates are the single biggest reason to ship a React Native app instead of two native ones, and they're also the single easiest way to push a crash to 100% of your users in under a minute. The tooling in 2026 is good enough that the second part is a process problem, not a technical one.
What's your rollout setup look like — EAS, self-hosted, or still nursing a CodePush fork along? Drop it in the comments, especially if you've got a bricking story. Those are the ones everyone learns from.
Top comments (0)