All articles
Mobile DevelopmentMay 13, 2026 6 min read

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.

Shipping OTA Updates with Expo EAS Without Bricking Production

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:

  • development channel → development branch, internal builds only
  • preview channel → preview branch, internal TestFlight / Play internal testing
  • production channel → typically points at a branch like production-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:

  1. Publish to the new branch, point production at it with a 5% rollout.
  2. Watch crash-free sessions and key business metrics for 30–60 minutes.
  3. Step to 25%, then 50%, then 100% over a few hours.
  4. 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.updateId as a tag on every session.
  • JS bundle load failures. expo-updates emits 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:

  1. Switch your runtime version policy to fingerprint today. Everything else depends on it.
  2. Set up three channels (development, preview, production) and map production to a versioned branch, not the channel name directly.
  3. Tag every crash report with the current updateId, channel, and runtimeVersion.
  4. Make staged rollouts the default. 5% → 25% → 100%, with a defined wait between each step.
  5. 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.

#React Native#Expo#EAS#Mobile DevOps#OTA

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project