EAS Update in Production: What OTA Actually Ships (and What It Can't)
OTA updates are the killer feature of Expo, until they aren't. Here's what EAS Update can and cannot ship in 2026, how to roll back safely, and where we've been burned in production.
Every team that ships React Native reaches the same fork in the road: your users are on v1.4.2, you have a one-line fix, and the App Store review queue is somewhere between 18 hours and "we'll get back to you." EAS Update is the reason a lot of teams pick Expo in the first place. It's also the feature that quietly causes the most production incidents when nobody documents the rules.
This is a field guide to what OTA actually ships in 2026, what it doesn't, and the release-ops patterns that have saved us from shipping a broken bundle to a million devices.
What EAS Update actually replaces
EAS Update ships a new JavaScript bundle and its bundled assets (images, fonts, JSON) to already-installed apps. That's it. It does not ship:
- Changes to native code (Swift, Kotlin, Objective-C, Java, C++)
- New native modules or upgraded versions of existing ones
- Changes to
Info.plist,AndroidManifest.xml, entitlements, or permissions - New app icons, splash screens, or bundle identifiers
- Anything in the
ios/orandroid/directories - Anything that requires a different
runtimeVersion
If you think of your app as two layers — a native shell and a JS payload — EAS Update replaces the payload. The shell is frozen the moment the store build was signed.
This is the mental model to hammer into your team. Every argument about "can we OTA this?" reduces to: does this change touch the shell, or just the payload?
The runtimeVersion contract
runtimeVersion is the contract between a native build and the OTA updates it's allowed to receive. In app.json:
{
"expo": {
"runtimeVersion": {
"policy": "fingerprint"
},
"updates": {
"url": "https://u.expo.dev/your-project-id",
"fallbackToCacheTimeout": 0
}
}
}
The fingerprint policy, which is the sane default in 2026, hashes your native dependencies and config. Change a native module version, bump a pod, add a permission — the fingerprint changes, the runtime version changes, and the store build you just cut will no longer accept your next OTA. That's a feature, not a bug. It's what stops you from pushing a JS bundle that calls a native method that doesn't exist on the device.
The failure mode we see most often: a developer adds react-native-something-new, ships an OTA, and half the user base crashes on launch because they're still on the previous store build with a different fingerprint. If you're on fingerprint, EAS will refuse to send that update to the old shell. If you're still on the older sdkVersion or appVersion policies — migrate. Today.
Channels, branches, and how not to nuke production
EAS Update separates channels (what a build subscribes to, baked in at build time) from branches (what you publish to, changeable anytime). A build on the production channel points at whatever branch is currently mapped to production.
Our standard setup:
developmentchannel →developmentbranch, internal dev buildspreviewchannel →previewbranch, TestFlight and internal Android testersproductionchannel →productionbranch, store builds
Publishing a new bundle:
eas update --branch production --message "fix: crash on empty cart"
The critical trick: because channels map to branches, you can reroute production traffic without republishing. If yesterday's update is bad, don't publish a new one and hope. Point the channel back at a known-good branch:
eas channel:edit production --branch production-rollback-2026-03-14
Or use the newer rollback command, which republishes the previous good update as the head of the current branch:
eas update:rollback --branch production
Either way, the next time a device checks for updates, it gets the old bundle back. In our experience, propagation to most active users happens within minutes; stragglers can take a session or two depending on your update check strategy.
Staged rollouts
Since late 2024, EAS Update supports rollouts — publishing an update to a percentage of your users. Start at 5%, watch your crash reporter, ramp up. This is the single biggest reduction in OTA risk we've made in the last two years.
eas update --branch production --rollout-percentage 10
eas update:rollout --branch production --percentage 50
eas update:rollout --branch production --republish # 100%
Pair it with Sentry or a similar crash reporter tagged by update ID, and you have a real feedback loop instead of a prayer.
What we've been burned by
1. The empty asset regression
An asset that exists at build time but is removed in a later JS bundle can crash on Android if the manifest still references it. Ship assets additively. If you're removing an image, keep the file for one release cycle before you actually delete it.
2. Silent native module drift
A team member upgrades expo-image locally, everything works in Expo Go, they commit. The next OTA ships JS that calls a new prop. The store build doesn't have it. Fingerprint policy would have caught this — but only if the upgrade actually changed native code, which not every version bump does. Enforce this in CI: run expo prebuild and diff the generated native directories against the last store build's snapshot. Anything different means you need a new store submission, not an OTA.
3. App Review and OTA scope
Apple's guidelines (specifically 3.3.1 / the developer agreement) permit OTA changes as long as they don't materially alter the app's purpose or add features you didn't disclose. "We added a whole new tab via OTA" is the kind of thing that gets accounts flagged. Bug fixes, copy changes, non-feature-flag experiments — fine. New paid features, new data collection, new SDK integrations — cut a store build.
We've never seen Apple actually reject an OTA (they can't review them). We have seen accounts get warning emails after users complained about features appearing between store releases. Behave.
4. Update check timing
By default, Expo checks for updates on cold start. If you have a chatty splash screen and fallbackToCacheTimeout: 0, users get the cached bundle immediately and the new one next launch. That's usually what you want. If you actually need users on the fix now, block briefly:
import * as Updates from 'expo-updates';
async function checkForUpdate() {
try {
const result = await Updates.checkForUpdateAsync();
if (result.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
} catch {
// Never let the update check block app launch on error
}
}
Wrap it in a timeout. Never, ever let a failed CDN call keep users staring at your splash screen.
OTA vs. store build: the decision table
| Change | OTA? |
|---|---|
| Copy, translations, styling | Yes |
| New screen built with existing components | Yes |
| Bug fix in existing JS logic | Yes |
| Feature flag flip | Yes |
| Upgrade a JS-only library | Usually yes (verify no native peer deps changed) |
| Upgrade Expo SDK | No |
| Add a new native module | No |
| Change permissions or entitlements | No |
| Change app icon or splash | No |
| Change minimum OS version | No |
When in doubt, run npx expo-doctor and check whether the fingerprint changed. If it did, you're cutting a build.
The Swift/Kotlin honesty check
If you're fully native, you don't get any of this. CodePush for native shells was deprecated by Microsoft in 2024, and Apple has never officially blessed shipping executable code out-of-band. The closest native equivalents are remote config (Firebase Remote Config, LaunchDarkly) and server-driven UI. Both are valuable, neither is a replacement for shipping a bundle.
This is a real, unglamorous reason teams pick React Native + Expo in 2026: the ability to fix a P0 in an hour without waiting on review is worth a lot when your app is a revenue channel. If you'd like to talk through where cross-platform makes sense for your product, our mobile development services page has more on how we scope this.
Where we'd start
If you're setting this up fresh: switch to fingerprint runtime versioning today, wire up three channels that mirror your environments, and add a rollout gate at 10% for every production publish. Tag every update ID into your crash reporter. Write a one-page runbook for how to roll back — and rehearse it once, on a Wednesday afternoon, before you need it at 2 a.m. on a Saturday.
OTA is a superpower. Treat it like one, with the safety catches on.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Background Tasks in React Native 2026: What Still Breaks on iOS and Android
Background execution is where cross-platform promises go to die. Here's what actually runs on iOS 18 and Android 15 in 2026, where Expo helps, and when you have to drop into Swift or Kotlin.

Surviving App Store Review in 2026: The React Native Rejections We Keep Seeing
A field guide to the App Store and Play Store rejections that hit React Native and Expo apps hardest in 2026 — and how to ship a build that actually gets through on the first try.

Push Notifications in React Native 2026: Expo Notifications vs FCM vs OneSignal in Production
We've shipped push on all three stacks — Expo Notifications, raw FCM/APNs, and OneSignal. Here's how they actually behave in production, including the token-refresh bugs nobody warns you about.
