Shipping OTA Updates with Expo EAS Without Bricking Production
OTA updates are the best and worst part of React Native. Here's how we run Expo EAS Update in production without shipping a broken JS bundle to every user at once.

Over-the-air updates are the single most powerful feature in the React Native toolbox — and the single fastest way to ship a white screen to a million phones before lunch. We've shipped a lot of Expo EAS Update rollouts across client apps, and the difference between "that was great" and "call the on-call engineer" almost always comes down to process, not tooling.
This is the playbook we actually use.
Why OTA still matters in 2026
Apple and Google have both tightened the screws on what you can change OTA. You can't swap native code, you can't materially change the app's purpose, and Apple's guideline 3.3.1 still wants you well-behaved. But within those limits, the value is enormous: a typo fix, a broken API contract, a feature flag bug, a payment screen regression — all of it can be patched in minutes instead of a 24–48 hour review cycle.
Expo EAS Update has more or less become the default for React Native teams that aren't running a fully bespoke CodePush replacement. It's tightly coupled to expo-updates, integrates with EAS Build, and the channel model maps cleanly onto how most teams already think about release tracks.
The mental model: runtime versions are the contract
The single concept most teams get wrong is the runtime version. An OTA update is just a JavaScript bundle plus assets. It assumes a specific set of native modules with specific native code. If the JS bundle calls NativeModuleX.doThing() and that module isn't in the installed binary, the app crashes on launch. No amount of careful JS review will save you.
The runtime version is the contract between a JS bundle and a native binary. EAS will only deliver an update to a device whose installed binary advertises a matching runtime version.
In app.json (or app.config.ts):
{
"expo": {
"runtimeVersion": {
"policy": "fingerprint"
},
"updates": {
"url": "https://u.expo.dev/your-project-id"
}
}
}
The fingerprint policy (stable in recent Expo SDKs) hashes your native dependency graph and config plugins. Add a new native module? The fingerprint changes, the runtime version changes, and EAS will refuse to send the new JS to old binaries. That refusal is a feature. It's the guardrail that stops you from bricking production.
When to bump the binary
If you change anything that touches native code — adding a library with a native module, updating Expo SDK, changing config plugins, modifying entitlements — you need a new binary in the store. OTA is for JS, assets, and pure-JS dependency upgrades. Treat any PR that modifies package.json with suspicion and check whether the dependency ships native code.
Channels, branches, and the release train
EAS Update separates channels (what a binary subscribes to) from branches (what publishers push to). A binary built for the production channel reads whatever branch is currently mapped to production. That indirection is what makes safe rollouts possible.
Our standard setup:
developmentchannel →developmentbranch, internal builds onlypreviewchannel →previewbranch, internal TestFlight / Play internal testingproductionchannel → typically points at a branch likeproduction-v2-14-0
The production channel pointing at a versioned branch is the bit most teams skip. It means we can cut a production-v2-14-1 branch, publish to it, point production at it, and if anything goes wrong, repoint production back to production-v2-14-0 instantly.
# Publish a fix to a new branch
eas update --branch production-v2-14-1 --message "Fix checkout total rounding"
# Point production traffic at it
eas channel:edit production --branch production-v2-14-1
# Something broke? Roll back in seconds
eas channel:edit production --branch production-v2-14-0
That last command is the one you want at 11pm on a Friday. Practice it.
Staged rollouts: don't ship to everyone at once
EAS supports rollout percentages on a branch. We almost never publish straight to 100%. Our default pattern:
- Publish to the new branch, point production at it with a 5% rollout.
- Watch crash-free sessions and key business metrics for 30–60 minutes.
- Step to 25%, then 50%, then 100% over a few hours.
- If anything looks off at any step, drop the rollout to 0% or repoint the channel.
eas update:roll-out-percentage --branch production-v2-14-1 --percentage 25
The gotcha: rollout percentage is sticky per device. A device that got the update at 5% keeps it at 25%. Rolling back by lowering the percentage doesn't pull the bundle from devices that already have it. To actually undo, you repoint the channel to the previous branch — and even then, devices won't pick up the old bundle until next launch.
What you should monitor
A rollout you can't observe is a rollout you can't trust. The minimum we wire up:
- Crash-free sessions, segmented by update ID. Sentry, Bugsnag, and Crashlytics all support custom tags — set the current
Updates.updateIdas a tag on every session. - JS bundle load failures.
expo-updatesemits events for download failures and rollback. Pipe them to your analytics. - Business KPIs that matter. For an e-commerce app, that's add-to-cart and checkout completion. For a content app, session length and scroll depth.
A useful pattern at app start:
import * as Updates from 'expo-updates';
import * as Sentry from '@sentry/react-native';
Sentry.setTag('updateId', Updates.updateId ?? 'embedded');
Sentry.setTag('channel', Updates.channel ?? 'unknown');
Sentry.setTag('runtimeVersion', Updates.runtimeVersion);
Now every crash in Sentry tells you which JS bundle the user was running. Without this tag, debugging an OTA regression is archaeology.
The failure modes nobody warns you about
The "silent native dependency" disaster
Someone updates a pure-JS-looking package that quietly added a native module in its latest version. The fingerprint policy catches this if your CI is configured to fail when fingerprints diverge from the deployed binary. If you're using a manual appVersion-style runtime version policy, you'll happily ship a JS bundle that crashes on launch. Use fingerprint.
The update-on-launch UX
By default, expo-updates checks for updates on cold start and applies them on the next launch. This means users see the broken version once, then the fixed version. For critical fixes, you can force the update to apply immediately:
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
Don't do this on every launch — it's a terrible startup experience. Gate it behind a remote config flag so you can turn it on only when you need to force a critical fix through.
The store-review version mismatch
Apple reviewers test the binary you submitted. If your OTA mechanism delivers a substantially different experience to real users than what reviewers see, you risk rejection or, worse, post-launch removal. Keep OTA changes scoped to fixes and incremental improvements between binary releases. Major feature launches go through the store.
Where this fits next to native
If you're comparing this to Swift or Kotlin: there is no equivalent. Native apps wait for store review for every change. That's the core trade-off. React Native with EAS Update gives you a release valve that native teams simply don't have, at the cost of a more complex mental model around runtime versions and a non-trivial native dependency in expo-updates.
For most product teams shipping iterative features on a weekly cadence, that trade is wildly in favor of OTA. For an app that's mostly stable and ships quarterly, the operational overhead is harder to justify.
Where we'd start
If you're setting this up from scratch on a real product:
- Switch your runtime version policy to
fingerprinttoday. Everything else depends on it. - Set up three channels (
development,preview,production) and map production to a versioned branch, not the channel name directly. - Tag every crash report with the current
updateId,channel, andruntimeVersion. - Make staged rollouts the default. 5% → 25% → 100%, with a defined wait between each step.
- Write down — actually write down — the rollback command and pin it somewhere your on-call can find it at 11pm.
If you want a hand designing the release pipeline or auditing an existing Expo setup, our mobile development team does this work day in, day out.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

App Size Bloat in React Native: How We Got an Expo App Back Under 40 MB
An Expo app crept past 120 MB on Android. Here's the audit we ran, the libraries we ripped out, and the build flags that actually moved the needle.
Deep Linking in React Native 2026: Universal Links, App Links, and the Edge Cases That Bite
Deep linking looks trivial until a marketing campaign goes live and half your users land on the App Store instead of the screen you promised. Here's what actually works in 2026.

Expo Dev Client vs Expo Go in 2026: Stop Fighting Your Own Tooling
Expo Go is great until it isn't. Here's when to graduate to a custom dev client, what breaks during the switch, and how to keep your team productive while you migrate.
