React Native OTA Updates with EAS Update: Rollouts, Rollbacks, and CI/CD

Ship bug fixes and features to your React Native app instantly with EAS Update. Covers setup, runtime versioning, gradual rollouts, instant rollbacks, code signing, and CI/CD automation with EAS Workflows.

Shipping a bug fix to your React Native app shouldn't mean waiting days for App Store review. We've all been there — you spot a typo on the login screen or a broken layout, and the fix takes two minutes but the review cycle takes two days. Over-the-air (OTA) updates let you push JavaScript and asset changes directly to users' devices, bypassing the store entirely. And with Expo's EAS Update, this process is genuinely production-grade: versioned, staged, signed, and wired into your CI/CD pipeline.

This guide walks you through everything you need to deploy OTA updates confidently — from initial setup to percentage-based rollouts, instant rollbacks, end-to-end code signing, and fully automated workflows with EAS Workflows.

How EAS Update Works Under the Hood

EAS Update lets your app replace its JavaScript bundle and static assets without requiring a new binary build. Here's the lifecycle in a nutshell:

  1. You run eas update from your terminal or CI environment.
  2. The EAS CLI bundles your latest JavaScript and static assets using Metro.
  3. The bundle gets uploaded to Expo's globally distributed CDN.
  4. The update is assigned to a branch, which is linked to a channel.
  5. When a user opens your app, expo-updates checks the CDN for a new update matching the app's runtime version and channel.
  6. If one's available, it downloads in the background and applies on the next app restart.

The nice thing here is that users on spotty connections still see the cached version immediately. The new version silently downloads and is ready next time they open the app. No loading spinners, no forced waits.

Setting Up EAS Update in Your Expo Project

Step 1: Install Dependencies

Start by installing the expo-updates library and EAS CLI:

npx expo install expo-updates
npm install -g eas-cli

Step 2: Configure EAS

If you haven't already initialized EAS in your project, run:

eas init
eas update:configure

This adds the necessary configuration to your app.json (or app.config.js), including a runtimeVersion and your project's EAS project ID.

Step 3: Set Your Runtime Version Policy

The runtime version is how EAS Update knows whether an update is compatible with a given binary. This is honestly one of the most important pieces to get right. Add this to your app config:

{
  "expo": {
    "runtimeVersion": {
      "policy": "appVersion"
    },
    "updates": {
      "url": "https://u.expo.dev/your-project-id"
    }
  }
}

The appVersion policy ties the runtime version to your app's version field (e.g., 1.2.0). Whenever you change native code, bump the version and create a new binary build. For JS-only updates, the version stays the same and updates flow seamlessly.

Step 4: Create a Build That Supports Updates

OTA updates only work in production or preview builds — not in Expo Go or debug mode. Create a build with:

eas build --platform all --profile production

This build includes the expo-updates native module and is configured to check for updates on launch.

Publishing Your First OTA Update

Once your build is on a device or in a store, pushing an update is surprisingly straightforward:

eas update --channel production --message "Fix login button alignment"

That's it. Seriously. Users on the production channel with a matching runtime version will receive this update the next time they open the app. You can verify the update was published with:

eas update:list

Understanding Channels and Branches

EAS Update uses a channel → branch mapping that gives you fine-grained control over which updates reach which users. This tripped me up at first, but once it clicks, it's really elegant:

  • Channel — embedded in the binary at build time. Think of it as the distribution pipeline. Common channels: production, preview, staging.
  • Branch — a stream of updates, similar to a Git branch. A channel points to one branch at a time (unless a rollout is active).

The separation is powerful. You can test an update by publishing to a staging branch, then point the production channel to that branch once verified — without republishing anything.

# Publish to staging branch
eas update --branch staging --message "Test new checkout flow"

# After testing, point production channel to staging branch
eas channel:edit production --branch staging

Runtime Versioning: Keeping Native Code in Sync

Runtime versioning is probably the most critical concept to understand with OTA updates. An update can only change JavaScript and assets — it cannot modify native code. If your update references a native module that doesn't exist in the binary, the app will crash. Full stop.

Runtime Version Policies

PolicyDerives FromBest For
appVersionversion in app.jsonApps with custom native code that bump version on each release
nativeVersionios.buildNumber + android.versionCodeApps where native and JS release cycles differ
Manual stringHardcoded value like "1.0.0"Full control over compatibility boundaries

What Happens on a Mismatch

If you publish an update that requires a native module not present in the build, expo-updates detects the error at runtime and tries to roll back to the previous working update automatically. That said, this safety net has limits — some crashes happen too early for the recovery logic to kick in. Always bump the runtime version when you add, remove, or update native dependencies. Don't learn this one the hard way.

Gradual Rollouts: Deploying to a Percentage of Users

Pushing an update to 100% of users instantly is risky. I've seen teams ship a "quick fix" that broke something else entirely and it went straight to every user. EAS Update supports two rollout strategies to avoid exactly that scenario.

Per-Update Rollouts

Roll out a specific update to a fraction of users:

# Publish to 10% of users
eas update --channel production --rollout-percentage 10 --message "New onboarding flow"

# Check rollout status
eas update:list

# Increase to 50% after monitoring
eas update:edit --rollout-percentage 50

# Go to 100% when confident
eas update:edit --rollout-percentage 100

One gotcha: while a rollout is in progress, you can't publish new updates to the same channel with --channel. Use --branch instead to target a specific branch.

Branch-Based Rollouts

Roll out an entire branch (a stream of updates) to a percentage of users on a channel:

# Start an interactive rollout
eas channel:rollout

This is handy when you have multiple related updates on a feature branch and want to test the entire set together before promoting them to all users.

Monitoring Your Rollout

After deploying to a small percentage, keep an eye on the EAS dashboard for:

  • Update adoption rate — How many users in the rollout percentage have actually received the update.
  • Error rate — Any increase in JS errors or crashes post-update.
  • Performance metrics — Check your Sentry or analytics dashboard for regressions.

If error rates spike, cancel the rollout immediately. Don't wait to see if it "settles down."

Rollbacks: Reverting a Bad Update

When something goes wrong — and eventually it will — you need to act fast. EAS Update gives you multiple rollback mechanisms.

Manual Rollback

# Rollback the latest update on production
eas update:rollback --channel production

This republishes the previous stable update, effectively overwriting the problematic one. Users receive the rolled-back version on their next app launch.

Reverting a Rollout in Progress

# Revert an update-based rollout
eas update:revert-update-rollout

# For branch-based rollouts, use the interactive command
eas channel:rollout

Reverting republishes the control update (the one users had before the rollout started), putting everyone back to the known-good state.

Automatic Error Recovery

expo-updates includes built-in error recovery. If an update crashes the app during startup, the library detects the failure and rolls back to the previously cached working version. But be aware of the edge cases:

  • Updates that corrupt AsyncStorage or local state may not be caught.
  • Crashes that happen before expo-updates initializes can't be recovered from.
  • In the worst case, users may need to uninstall and reinstall.

This is exactly why gradual rollouts are essential — they limit the blast radius when things go sideways.

Programmatic Updates with the expo-updates API

The default behavior checks for updates on every app launch. But sometimes you want more control — maybe you want to show users a prompt, or check for updates at a specific point in your app's lifecycle. The expo-updates JavaScript API gives you that.

Manual Update Check and Apply

import * as Updates from 'expo-updates';

async function checkAndApplyUpdate() {
  try {
    const update = await Updates.checkForUpdateAsync();
    if (update.isAvailable) {
      await Updates.fetchUpdateAsync();
      // Prompt the user before reloading
      Alert.alert(
        'Update Available',
        'A new version has been downloaded. Restart now?',
        [
          { text: 'Later', style: 'cancel' },
          { text: 'Restart', onPress: () => Updates.reloadAsync() },
        ]
      );
    }
  } catch (error) {
    console.error('Update check failed:', error);
  }
}

The useUpdates Hook

For a more reactive approach, there's the useUpdates() hook (introduced in SDK 53):

import { useUpdates } from 'expo-updates';

function UpdateBanner() {
  const { isUpdateAvailable, isUpdatePending } = useUpdates();

  if (isUpdatePending) {
    return (
      <View style={styles.banner}>
        <Text>Update ready! Restart to apply.</Text>
        <Button title="Restart" onPress={() => Updates.reloadAsync()} />
      </View>
    );
  }

  if (isUpdateAvailable) {
    return (
      <View style={styles.banner}>
        <Text>Downloading update...</Text>
        <ActivityIndicator />
      </View>
    );
  }

  return null;
}

Background Updates

Starting with SDK 53, you can check for and download updates while the app is in the background using expo-background-task:

import * as TaskManager from 'expo-task-manager';
import * as Updates from 'expo-updates';

const BACKGROUND_UPDATE_TASK = 'background-update-check';

TaskManager.defineTask(BACKGROUND_UPDATE_TASK, async () => {
  const update = await Updates.checkForUpdateAsync();
  if (update.isAvailable) {
    await Updates.fetchUpdateAsync();
    // Update will apply on next foreground launch
  }
  return update.isAvailable
    ? TaskManager.BackgroundFetchResult.NewData
    : TaskManager.BackgroundFetchResult.NoData;
});

This ensures users always have the latest version ready, even if they haven't opened the app in a while. Pretty neat for apps where you really need everyone on the same version.

Channel Surfing: Switching Channels at Runtime

Introduced in SDK 54, channel surfing lets you switch a production build between update channels at runtime. This opens up some interesting possibilities:

  • Internal testing — Let QA testers switch to a staging channel without needing a separate build.
  • Feature flags — Route specific users to a channel with experimental features.
  • Enterprise deployments — Different clients receive different update streams from the same binary.

Configure it using the runtime override API in expo-updates version 0.29.0+. One thing to note: the overridden channel takes effect after the app is closed and reopened — it doesn't apply mid-session.

End-to-End Code Signing

For apps with strict security requirements — banking, healthcare, enterprise — EAS Update supports end-to-end code signing with public-key cryptography. If you're in a regulated industry, this is probably non-negotiable.

How It Works

  1. You generate a private/public key pair locally.
  2. The public key (as a certificate) is embedded in your binary at build time.
  3. When you run eas update, the CLI signs the update bundle with your private key locally — the key never leaves your machine.
  4. On the device, expo-updates verifies the signature against the embedded certificate before applying the update.
  5. If the signature doesn't match, the update is rejected and the app continues running the previous trusted version.

This makes EAS Update "trustless" infrastructure — even if the CDN or EAS servers were compromised, your users would only run code you signed. That's a strong guarantee.

Code signing is available on EAS Production and Enterprise plans. Pro tip: have a key rotation strategy figured out before you actually need one. You don't want to be scrambling when a key expires.

CI/CD with EAS Workflows

Manual updates work fine for quick fixes, but production teams need automation. EAS Workflows lets you trigger updates from GitHub events, and the setup is refreshingly simple.

Automatic Updates on Push to Main

Create .eas/workflows/ota-update.yml:

name: OTA Update on Push
on:
  push:
    branches:
      - main

jobs:
  update:
    name: Publish OTA Update
    type: update
    params:
      platform: all
      channel: production
      message: "Auto-update from commit ${{ github.sha }}"

Preview Updates on Pull Requests

Create .eas/workflows/pr-preview.yml:

name: PR Preview Update
on:
  pull_request:
    branches:
      - main

jobs:
  preview:
    name: Publish Preview Update
    type: update
    params:
      platform: all
      channel: preview
      message: "Preview for PR #${{ github.event.pull_request.number }}"

With this setup, every PR gets its own preview update that testers can load on a preview build, and every merge to main automatically pushes to production. It's the kind of workflow that makes you wonder why you ever did it manually.

Combining Builds and Updates

For a complete pipeline, your workflow can conditionally trigger a new build when native code changes and an OTA update when only JavaScript changes. Use a fingerprint-based runtime version or check the diff for changes in ios/, android/, or native dependency additions.

What Can and Cannot Be Updated OTA

Understanding the boundary between what OTA can and can't do prevents some nasty surprises in production. Here's the breakdown:

Can Update OTARequires New Binary
JavaScript business logicNew native modules or libraries
React component treesChanges to app.json native fields (permissions, scheme)
Styles and layoutsUpdated native SDKs (maps, push, etc.)
Static images and assetsApp icon or splash screen changes
Navigation structureExpo SDK major version upgrades
API endpoint URLsAndroid/iOS permission changes

When in doubt, ask yourself: "Does this touch anything outside of JavaScript and assets?" If yes, you need a new build.

SDK 55 Improvements: Bundle Diffing

Expo SDK 55 introduced bundle diffing for EAS Update, and it's a game-changer for large apps. Instead of downloading the entire JavaScript bundle on every update, the client only downloads the diff between the current and new bundles. For large apps, this can reduce update sizes by 60–80% — that means faster downloads and significantly less data usage for your users.

The best part? Bundle diffing works automatically. No configuration needed. Just make sure you're on SDK 55 or later with the latest expo-updates package and you're good to go.

Best Practices for Production OTA Updates

  1. Always use rollouts for production — Start at 5–10% and increase gradually. Monitor error rates on the EAS dashboard before going to 100%.
  2. Test on a preview channel first — Create a preview build with the same runtime version and test updates there before publishing to production. This extra step has saved me more than once.
  3. Bump runtime version on native changes — Any time you add, remove, or update a native dependency, increment your runtime version and create a new build. No exceptions.
  4. Keep updates small — OTA updates should be focused fixes and improvements. Large feature releases are better suited for full binary updates with proper store review.
  5. Handle offline users — Users without connectivity keep their cached version. Don't rely on OTA for time-critical mandatory updates; use the programmatic API to enforce update checks when connectivity returns.
  6. Follow app store guidelines — Both Apple and Google allow OTA updates for bug fixes and minor improvements, but significant behavior changes should go through store review. Don't push your luck here.
  7. Enable code signing for sensitive apps — If your app handles financial transactions, healthcare data, or enterprise workflows, code signing adds a critical security layer.
  8. Monitor post-update — Integrate Sentry or a similar error tracking tool to catch regressions quickly. Set up alerts for error rate spikes right after deployments.

Frequently Asked Questions

Do OTA updates work with Expo Go?

No. OTA updates via EAS Update only work in production or development builds that include the expo-updates native module. Expo Go uses its own update mechanism tied to the development server. To test OTA updates, you'll need to create a development or preview build using eas build.

How large can an OTA update be?

There's no hard size limit, but smaller updates download faster and are more reliable on poor connections. With SDK 55's bundle diffing, updates typically download only the changed portions of the bundle. For best results, keep individual updates focused on specific fixes rather than bundling large feature releases into a single OTA push.

Can I force users to update immediately instead of on next launch?

Yes, you can. Use the programmatic API with Updates.checkForUpdateAsync() and Updates.fetchUpdateAsync(), followed by Updates.reloadAsync(). You can show a blocking modal during the download and reload the app immediately. That said, use this sparingly — forced restarts frustrate users if you overdo it.

What happens if an OTA update crashes the app?

The expo-updates library has built-in error recovery that detects startup crashes and automatically rolls back to the previous working update. However, some types of failures — like corrupted local state or crashes before the updates module initializes — can't be recovered from automatically. In rare worst-case scenarios, users may need to reinstall the app. (Yet another reason to use gradual rollouts.)

Is EAS Update free to use?

EAS Update is included in all Expo plans, including the free tier, which allows a limited number of monthly update downloads. Production and Enterprise plans offer higher limits, priority CDN delivery, and features like end-to-end code signing and advanced rollout controls. Check the current Expo pricing page for up-to-date plan details.

About the Author Editorial Team

Our team of expert writers and editors.