EAS Update in 2026: What OTA Actually Solves (and What Still Needs a Store Build)
OTA updates are the most misunderstood feature in Expo. Here's an honest breakdown of what EAS Update fixes in production, what it can't touch, and how to structure release channels so you don't ship a runtime mismatch.
Every few months a client asks us the same question: "Can we just ship this via OTA?" Sometimes the answer is yes and the fix lands in twenty minutes. Sometimes the answer is no, and the reason is not obvious until you understand what EAS Update actually replaces in the app bundle.
This is a field guide to that decision in 2026 — what OTA covers, what still forces a TestFlight or Play Console upload, and how we structure runtime versions and channels so a hotfix doesn't turn into a crash loop.
What EAS Update actually ships
EAS Update replaces the JavaScript bundle and the assets referenced from it. That is the whole product. It is not magic, and it is not a second app binary.
When your app boots, the Expo Updates module checks the update server for a newer bundle that matches the current runtimeVersion. If one exists, it downloads it in the background, and the next cold start runs the new JS. The native binary — everything compiled into the .ipa or .aab — is untouched.
So the mental model is simple:
- JS + static assets → shippable via OTA
- Native code, native modules, permissions, app icons, splash screens, entitlements → store build required
That clean line gets blurry the moment someone installs a new library. Which is why most OTA disasters we've seen come from misunderstanding runtimeVersion.
The runtimeVersion contract
runtimeVersion is the handshake between a JS bundle and a native binary. If they don't match, Expo Updates refuses to apply the update, which is the behaviour you want. What you don't want is bumping native code and forgetting to bump the runtime version, because then old binaries will happily download a JS bundle that calls into native modules they don't have.
In app.json we recommend the appVersion policy for most teams:
{
"expo": {
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/your-project-id",
"fallbackToCacheTimeout": 0
}
}
}
With the appVersion policy, every time you bump version in app.json you implicitly cut a new runtime. OTA updates only flow to matching binaries. If you're using CNG (Continuous Native Generation) and native dependencies are stable across a version, this is usually enough.
For teams with more discipline — or more custom native code — the fingerprint policy computes a hash of your native project. Add a native module, the fingerprint changes, and OTA channels split automatically. It's stricter and more correct, but it means your CI needs to publish separate updates for every fingerprint in the wild.
The trap: shared code, forked runtimes
We once inherited a project where the team had shipped four store builds in a month, each with a slightly different set of native modules, all pointing at the same production channel. Every OTA push reached about a third of users. The rest sat on stale JS because their fingerprint didn't match anything on the update server.
The fix isn't clever — it's publishing for every active runtime you still support. eas update --branch production publishes for the runtime of your current checkout. If you have three runtimes in the wild, you need three publishes, one per branch or per checked-out release tag.
What you cannot ship via OTA
This list is the one we hand to product managers before every release planning session.
- Any change to native dependencies. Adding
react-native-mmkv, upgradingreact-native-reanimatedpast a native ABI break, swapping the notification library — all store builds. - iOS permission strings (
NSCameraUsageDescriptionand friends). These live inInfo.plist. Change the copy, ship a binary. - New URL schemes, universal link domains, associated domains. Native config.
- App icon, splash screen, bundle identifier, display name. Native.
- Expo SDK upgrades. Almost always a native change under the hood.
- Hermes bytecode format changes across major RN versions. Rare, but a real reason to force a binary.
- Anything that changes entitlements — HealthKit, background modes, push capability. Apple checks these against the signed binary.
The rule of thumb: if you had to run pod install or edit anything in ios/ or android/, it's a store build. No exceptions we've found.
Where OTA earns its keep
With the limits established, here's where OTA is genuinely load-bearing:
- Copy and UX fixes after review. You shipped a confusing empty state; you can rewrite it and push in an hour.
- API contract patches. Backend changed a field name in production; frontend fix goes out same day.
- Feature flag defaults. A/B test defaults, remote config fallbacks, kill switches for a broken feature.
- Third-party SDK config — as long as the SDK itself is JS-only.
- Analytics and logging. New event names, redaction fixes for PII you accidentally captured.
What OTA is not good for: shipping a feature you didn't ship in the last store build. If reviewers didn't see it, using OTA to sneak it in is against both Apple's and Google's guidelines and gets apps pulled. This isn't theoretical — App Review 2026 still explicitly calls it out.
Channel and branch design that doesn't fall over
The defaults are fine for a solo project. For teams, we use this shape:
branches:
main-dev → channel: development
main-staging → channel: preview
release/* → channel: production
Each store build is configured with the channel that matches its purpose. eas.json handles it:
{
"build": {
"development": {
"developmentClient": true,
"channel": "development"
},
"preview": {
"distribution": "internal",
"channel": "preview"
},
"production": {
"channel": "production"
}
}
}
The subtle part: a channel can point at multiple branches, one per runtime. When you cut a new store version, you create a new branch (e.g. release/1.8.0) and point the production channel at it. Old binaries keep pulling from their old branch. New binaries get the new one. Nobody gets a bundle they can't run.
Rollback without panic
If a bad JS update goes out, you don't republish from your working tree — that's slow and mixes intent. You republish a known-good update:
eas update:republish --branch production --group <previous-update-group-id>
We've had teams keep a "last known good" tag in git specifically so the on-call engineer can find the right update group in under a minute at 2am. It's cheaper than reasoning about state during an incident.
The store-build-required checklist
Before any release, we run through this quickly:
- Did native dependencies change? → binary
- Did
app.jsonplugins,ios, orandroidsections change? → binary - Did permission strings, entitlements, or capabilities change? → binary
- Did the Expo SDK version change? → binary
- Everything else? → OTA candidate
If you're not sure, look at your fingerprint. npx expo-updates fingerprint:generate will tell you whether the native side actually changed. It's the ground truth.
A note on comparisons with Swift and Kotlin
On native iOS you don't get OTA at all — you get TestFlight, phased releases, and StoreKit config. On Android, App Bundles + staged rollouts get you closer, plus Play Feature Delivery for modules. Neither gives you what EAS Update gives a React Native team: same-day JS hotfixes without a review queue.
That single capability is often the reason product teams pick React Native + Expo over going native, and it's worth the tradeoffs. But it's not a substitute for review discipline. Ship what you showed the reviewer. Fix bugs with OTA. Ship features with binaries.
Where we'd start
If you're setting this up from scratch, do three things in order. First, pick a runtimeVersion policy today — appVersion if you're moving fast, fingerprint if you have custom native code. Second, wire your CI to publish an EAS Update on every merge to your release branches, so "pushing an update" isn't a manual ritual you'll forget under pressure. Third, write down your rollback command in your runbook, with the exact channel and branch names filled in.
If you want a second pair of eyes on your release ops, that's exactly the kind of thing we do on mobile engagements. OTA is a sharp tool. It's worth setting up so it stays sharp.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Push Notifications in React Native 2026: Expo Notifications vs FCM/APNs Direct
Expo Notifications is the easy path, but at some scale you outgrow it. Here's an honest breakdown of when to stay on Expo's push service and when to talk to FCM and APNs directly.
In-App Purchases in React Native 2026: RevenueCat vs Rolling Your Own
A practical breakdown of when RevenueCat earns its cut and when a direct StoreKit 2 / Google Play Billing integration is the saner call for React Native teams in 2026.
React Native New Architecture Migration in 2026: What Actually Breaks
Fabric and TurboModules are the default in 2026, but migrating a real app is still messy. Here's what breaks, what to fix first, and where the interop layer saves you.
