OTA Updates in Expo 2026: EAS Update, Rollbacks, and the App Store Rules That Bite
OTA updates are the best feature of Expo — and the fastest way to get your app pulled from review. Here's how we ship EAS Update safely in 2026, including rollback strategy and the store rules teams keep tripping on.
OTA updates are the single best reason to build on Expo instead of raw React Native. They are also the single fastest way to get your app pulled from review, brick a cohort of users, or spend a Saturday chasing a crash that only exists on one specific runtimeVersion.
We ship EAS Update across a dozen production apps at 72Technologies, and the mental model that keeps us out of trouble has almost nothing to do with the happy path Expo shows in the docs. Here's what actually matters in 2026.
What EAS Update actually ships (and what it can't)
EAS Update pushes a new JavaScript bundle and its associated assets — images, fonts, JSON — to a channel. Users on a build subscribed to that channel download the new bundle on next launch, verify it against a runtimeVersion, and load it instead of the bundle embedded in the binary.
What it does not ship:
- Native modules or any change to
ios/orandroid/directories app.json/app.config.tsfields that affect native code (permissions, entitlements, URL schemes, background modes)- Anything that alters the New Architecture flags, Hermes settings, or minimum OS versions
- Native dependency upgrades — even patch-level ones with autolinking side effects
If your PR touches any of the above, you need a new binary submitted through TestFlight or Play Console. Trying to squeeze a native change through OTA is how you ship a crash-on-launch to 100% of users at 6pm on a Friday.
The runtimeVersion contract
This is the field people misunderstand most. runtimeVersion is not your app version. It's a fingerprint of the native layer. If a build was compiled with runtimeVersion: "1.4.0", it will only accept OTA updates published to the same channel and the same runtime version.
We use runtimeVersion: { policy: "fingerprint" } on every project now. It hashes the native project and produces a version automatically, so if someone adds a new native module and forgets to bump anything, EAS refuses to serve the update to old binaries instead of shipping a crash.
{
"expo": {
"runtimeVersion": { "policy": "fingerprint" },
"updates": {
"url": "https://u.expo.dev/your-project-id",
"fallbackToCacheTimeout": 0,
"checkAutomatically": "ON_LOAD"
}
}
}
Channels, branches, and the rollout topology that survives contact with users
The default Expo tutorial suggests two channels: preview and production. That falls apart the moment you have more than one active app version in the wild.
The topology we run for anything past MVP:
development— feature branches, tied to Expo Go or dev clientsstaging— merged PRs, tied to internal TestFlight / Play Internalproduction-canary— first ~5% of prod usersproduction— everyone else
EAS branches map cleanly onto channels, so a release looks like: publish to staging, promote to production-canary, watch Sentry for 12–24 hours, then promote to production. Promotion is a pointer swap on the server — no republish, no risk of a rebuild introducing new bugs.
The rollback that actually works
EAS Update supports eas update:republish to point a channel back at an older update. This is your primary rollback tool. It works in seconds and doesn't require users to update anything.
But two gotchas:
- Users who already downloaded the bad update keep it until their next launch. If your bad update crashes on launch, they're stuck — the update loader never runs. Always ship with
expo-updatesconfigured so a crashed update auto-rolls back to the embedded bundle on next launch. - The embedded bundle in the binary is your true safety net. If OTA is completely broken, users still get the JS that shipped with the binary. Don't treat the embedded bundle as throwaway — QA it as if it were the release itself.
import * as Updates from 'expo-updates';
// In your root error boundary
if (__DEV__ === false && isFatal) {
await Updates.rollbackToEmbeddedAsync();
await Updates.reloadAsync();
}
The App Store rules that actually apply to OTA
This is the section people skip and regret. Apple's guidelines on remotely-loaded code are stricter than most teams realise, and reviewers have started flagging aggressive OTA patterns in the last 18 months.
The rule that matters is App Store Guideline 3.3.1 (via the Apple Developer Program License Agreement §3.3.2). Paraphrased: you can update your interpreted code, but the update must not "change the primary purpose of the Application by providing features or functionality that are inconsistent with the intended and advertised purpose" of the app as submitted.
In practice, this means:
- Adding a new screen, fixing a bug, tweaking copy: fine.
- Changing your app from a fitness tracker into a crypto wallet via OTA: not fine, and increasingly detected.
- A/B testing UI: fine.
- Server-side feature flags that unlock entire product surfaces post-review: risky, and we've seen rejections when the hidden feature is discovered.
Google Play is more permissive but has tightened via the Play Integrity API and the Developer Program Policies around "deceptive behaviour". The safe posture: if a feature is material to the app's purpose, ship it in a binary.
The IAP trap
Do not ship a change to purchase flows, subscription pricing display, or entitlement logic via OTA without extreme care. If your OTA update changes how a user sees a price or triggers a purchase, and it diverges from what was reviewed, you're inviting a rejection on the next binary submission. Reviewers compare.
We now treat any file under src/purchases/ as OTA-frozen between binary releases. If it needs to change, it goes in the next build.
A release checklist we actually use
Stolen from our internal runbook, lightly redacted:
Pre-publish
- [ ] Diff includes zero changes under ios/, android/, or app.config native fields
- [ ] runtimeVersion fingerprint matches target binary (eas update --dry-run)
- [ ] Sentry release created and sourcemaps uploaded
- [ ] Feature flags default to OFF for new surfaces
Publish to canary
- [ ] eas update --branch production-canary --message "..."
- [ ] Verify update visible in EAS dashboard
- [ ] Manual smoke test on a real device with canary channel
Soak (12–24h)
- [ ] Crash-free sessions holding steady vs previous update
- [ ] No spike in Updates.checkForUpdateAsync errors
- [ ] No new issues in Sentry above baseline
Promote
- [ ] eas update:republish --branch production --group <canary-group-id>
- [ ] Post in #release-log with update ID and rollback command ready
The "rollback command ready" line matters. Have the exact eas update:republish command for the previous known-good group pasted into the release channel before you promote. When something goes wrong at 11pm, you don't want to be grepping through the dashboard.
When to skip OTA entirely
OTA is a tool, not a default. We turn it off or drastically limit it when:
- The app is a thin wrapper around a web view — you already have server-side update capability, adding OTA is complexity for no gain.
- The app is in a regulated space where every user-facing change needs an audit trail tied to a specific reviewed binary.
- The team is small enough that shipping a binary via EAS Submit every two weeks is faster than maintaining the discipline OTA requires.
The honest comparison to native Swift/Kotlin: neither platform gives you a sanctioned equivalent. Firebase Remote Config and server-driven UI frameworks cover parts of the same ground, but none match the "ship a JS bug fix in 90 seconds" experience. That capability is real, and it's why we still reach for Expo on most greenfield mobile work. It's also a loaded gun.
Where we'd start
If you're adopting EAS Update on an existing app this quarter:
- Move to
runtimeVersion: { policy: "fingerprint" }before anything else. Everything downstream depends on it being correct. - Set up a canary channel and route 5–10% of production traffic to it. Even a manual promotion beats none.
- Wire
Updates.rollbackToEmbeddedAsync()into your root error boundary today, not after your first bad update. - Write the rollback runbook and pin it in Slack. The value of OTA is speed of recovery, not speed of shipping.
We cover the full release-ops setup — channels, monitoring, submit automation — in our mobile development services, and there's more on native release pipelines over on the blog. OTA is worth it. Just don't treat it as free.
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: APNs, FCM HTTP v1, and the Silent Delivery Failures
Push looks simple until you ship. Here's how APNs tokens, FCM HTTP v1, Expo's push service, and iOS focus modes conspire to eat your notifications — and what to actually do about it.

In-App Purchases in React Native 2026: RevenueCat, StoreKit 2, and the Receipts That Lie
IAP looks simple until refunds, family sharing, and grace periods hit production. Here's how we wire React Native purchases in 2026 without trusting the client.
App Size Bloat in React Native: Where the Megabytes Actually Come From in 2026
A field guide to shrinking React Native app binaries in 2026: Hermes bytecode, native modules, image assets, and the App Store Connect numbers that don't match what you built locally.
