All articles
Mobile DevelopmentOctober 18, 2026 7 min read

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.

EAS Update in 2026: A Field Guide to OTA Rollouts That Don't Brick Your App

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:

  1. development — mapped to the main branch, auto-updated on every PR merge
  2. preview — internal TestFlight / Play internal testing, mapped to preview
  3. production-canary — a slice of real users (usually 5 – 10%)
  4. 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:

  1. 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.
  2. A "stuck on old bundle" metric. Log the current Updates.updateId on 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.
  3. 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:

  1. Switch runtimeVersion to the fingerprint policy this week. It eliminates an entire category of outage.
  2. Add a production-canary channel and a small internal cohort. Even 50 engineers' phones is enough to catch most launch crashes before they hit real users.
  3. Wire the EAS update ID into Sentry and your product analytics so you can slice crash rate per update.
  4. 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.

#React Native#Expo#EAS#Release Engineering#Mobile DevOps

Want a team like ours?

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

Start a project