EAS Update in 2026: A Field Guide to OTA Rollouts That Don't Brick Your App
OTA updates in React Native sound like a cheat code until you ship a bad JS bundle to 40,000 devices at 2am. Here's how we structure EAS Update channels, rollouts, and rollbacks so that never happens twice.

OTA updates are the single most abused feature in the React Native stack. They feel like a cheat code — ship a fix in minutes, skip app review, keep users happy — right up until the day a bad JS bundle lands on 40,000 devices while you're asleep. This is the playbook we use at 72Technologies to run EAS Update in production without those 2am Slack pings.
Why EAS Update is still the default in 2026
CodePush is gone. Microsoft retired App Center in early 2025, and the community forks never really matured into something you'd stake a release on. That leaves Expo's EAS Update as the pragmatic default for most React Native teams, with a handful of shops rolling their own S3-plus-expo-updates setup for compliance reasons.
The appeal hasn't changed:
- Push a JS and asset bundle without going through App Store or Play review
- Patch a crash, a copy mistake, or a bad API contract in minutes
- Run A/B channels, feature-flag-ish rollouts, and canary groups
The risk hasn't changed either: you're shipping native-adjacent code outside the review process, and Apple's guideline 4.5.4 still says that update must not materially change the app's purpose or add features that should have been reviewed. Treat EAS Update as a hotfix channel and a controlled rollout channel, not a feature-shipping shortcut.
The mental model: channels, branches, runtime versions
If you don't internalise these three concepts, you will eventually brick someone's app. They are not interchangeable.
Runtime version
The runtime version is a fingerprint of your native layer. If you change native code — a new library with native modules, an upgraded Expo SDK, a modified AndroidManifest.xml — the runtime version must change. An update built for runtime 2024.11.0 must never be delivered to a binary running 2024.10.0. If it is, you get a white screen or a native crash on launch, and your users have no way to recover except reinstalling.
In app.json we use the fingerprint policy almost everywhere now:
{
"expo": {
"runtimeVersion": { "policy": "fingerprint" },
"updates": {
"url": "https://u.expo.dev/your-project-id",
"fallbackToCacheTimeout": 0
}
}
}
Fingerprint policy hashes your native dependencies and config, so you physically cannot ship an incompatible update to the wrong binary. Before fingerprint existed, we had a few incidents from engineers forgetting to bump appVersion. We don't miss those days.
Channels vs branches
- A channel is baked into the binary at build time (
production,staging,preview). It's what the installed app asks for. - A branch is what you publish updates to (
production,production-canary,hotfix-1.8.2). - A channel is mapped to a branch, and you can reassign that mapping at any time without rebuilding the app.
That indirection is the whole trick. It's how you do canaries, rollbacks, and emergency pivots without shipping a new binary.
Our default channel topology
For a typical consumer app we run four channels:
development— mapped to themainbranch, auto-updated on every PR mergepreview— internal TestFlight / Play internal testing, mapped topreviewproduction-canary— a slice of real users (usually 5 – 10%)production— everyone else
The canary channel is the one most teams skip, and it's the one that saves you. New builds ship with the production-canary channel to a small cohort (internal staff, opt-in beta users, or a randomised slice served via a separate build variant). You publish to canary first, wait, then promote.
# publish to canary
eas update --branch production-canary --message "fix: cart total rounding"
# after 24h of clean telemetry, promote by republishing from the same commit
eas update --branch production --message "promote: cart total rounding"
Yes, you publish twice. No, eas channel:edit to repoint the channel is not the same thing — repointing changes the mapping for everyone instantly, which defeats the point of a canary.
Staged rollouts without a feature-flag service
EAS supports rollout percentages on a branch, which is the closest thing to a native staged rollout you'll get without bolting on LaunchDarkly or Statsig. The flow we use:
# start a 10% rollout on the production branch
eas update --branch production --rollout-percentage 10 --message "v1.8.3"
# bump it after monitoring
eas update:republish --rollout-percentage 25
eas update:republish --rollout-percentage 50
eas update:republish --rollout-percentage 100
Two rules we hold firm on:
- Never increase rollout percentage without checking crash-free sessions. We watch Sentry's release health for the specific update ID. If crash-free sessions drop by more than a small margin against the previous update, we hold.
- Never roll out on a Friday. This is not superstition. Weekend on-call for a React Native rollback is miserable, and support volume for app issues spikes on Saturday mornings.
Rollbacks: the part everyone gets wrong
EAS Update does not have a true "rollback" button in the way Kubernetes does. What you have is eas update:rollback, which publishes an empty update that tells clients to fall back to the embedded bundle shipped with the binary. That is almost always what you want, but note the caveat: users who already downloaded the bad update will keep running it until they relaunch and check for updates.
If the bad update is actively crashing on launch, those users are stuck. The app crashes before it can fetch the rollback. This is why we always ship with:
import * as Updates from 'expo-updates';
Updates.checkForUpdateAsync()
.then(async (res) => {
if (res.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
})
.catch(() => {
// swallow — never let update checks crash the app
});
And we always wrap the update check in a timeout, so a slow CDN can never block the splash screen for more than a couple of seconds. The fallbackToCacheTimeout: 0 config means launch is never blocked by a network update check — the user gets the cached bundle, and the new one applies on next launch. For most consumer apps this trade-off is correct. If you absolutely need users on the latest bundle immediately (regulated industries, say), you flip this and accept slower cold starts.
The nuclear option
If an update has already crashed clients on launch and update:rollback can't reach them, your only remaining lever is a new native binary with the fix embedded. Expedited App Store review in 2026 is usually 2 – 8 hours in our experience, but don't bet the business on it. The real lesson: your canary should have caught this.
What you should never ship via OTA
Some changes feel like JS-only but will burn you:
- Anything that changes requested permissions or their copy — Apple wants that in a review
- Changes to IAP product IDs or purchase flow logic — StoreKit receipts can get weird across bundle swaps
- Deep link scheme changes
- Anything that materially changes the app's primary purpose (guideline 4.5.4)
- New third-party SDK initialisation, even if the SDK is pure JS — privacy manifests and tracking disclosures live at the binary level
When in doubt, ship a binary. The hours you save with an OTA aren't worth a developer account strike.
Observability: knowing an update is bad
You need three signals wired up before you trust any rollout:
- Crash-free session rate per update ID. Sentry, Bugsnag, and Firebase Crashlytics all support tagging releases with the EAS update ID — do it. Without this you're flying blind.
- A "stuck on old bundle" metric. Log the current
Updates.updateIdon every session start to your analytics. If 20% of your production channel is still on a bundle from two weeks ago, something is wrong with your update pipeline or your users' network conditions. - Launch-to-interactive timing. Regressions here are usually the first sign that someone added a synchronous require or a heavy top-level import.
Where we'd start
If you're running EAS Update today without any of this structure, don't try to adopt everything on Monday. In order:
- Switch
runtimeVersionto thefingerprintpolicy this week. It eliminates an entire category of outage. - Add a
production-canarychannel and a small internal cohort. Even 50 engineers' phones is enough to catch most launch crashes before they hit real users. - Wire the EAS update ID into Sentry and your product analytics so you can slice crash rate per update.
- Write a one-page rollback runbook and pin it in your mobile channel. When something goes wrong at 11pm on a Thursday, nobody wants to be reading the Expo docs for the first time.
If you want a second pair of eyes on your release pipeline, that's exactly the kind of work our mobile team does.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

In-App Purchases in React Native 2026: RevenueCat, StoreKit 2, and the Receipts That Lie
A field guide to shipping subscriptions in React Native in 2026 — what RevenueCat gets right, where StoreKit 2 still bites, and the receipt edge cases that quietly refund your MRR.

React Native New Architecture in 2026: What Actually Breaks When You Flip the Switch
Fabric and TurboModules are the default in 2026, but migrating a real app is still messy. Here's what breaks, what to patch first, and how to ship without a rollback week.

Expo Dev Clients vs Bare Workflow in 2026: When to Eject and When to Hold
Expo prebuild and dev clients cover more ground than ever in 2026, but there's still a line where going bare makes sense. Here's how we decide on real client projects.
