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:
- You run
eas updatefrom your terminal or CI environment. - The EAS CLI bundles your latest JavaScript and static assets using Metro.
- The bundle gets uploaded to Expo's globally distributed CDN.
- The update is assigned to a branch, which is linked to a channel.
- When a user opens your app,
expo-updateschecks the CDN for a new update matching the app's runtime version and channel. - 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
| Policy | Derives From | Best For |
|---|---|---|
appVersion | version in app.json | Apps with custom native code that bump version on each release |
nativeVersion | ios.buildNumber + android.versionCode | Apps where native and JS release cycles differ |
| Manual string | Hardcoded 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-updatesinitializes 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
- You generate a private/public key pair locally.
- The public key (as a certificate) is embedded in your binary at build time.
- When you run
eas update, the CLI signs the update bundle with your private key locally — the key never leaves your machine. - On the device,
expo-updatesverifies the signature against the embedded certificate before applying the update. - 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 OTA | Requires New Binary |
|---|---|
| JavaScript business logic | New native modules or libraries |
| React component trees | Changes to app.json native fields (permissions, scheme) |
| Styles and layouts | Updated native SDKs (maps, push, etc.) |
| Static images and assets | App icon or splash screen changes |
| Navigation structure | Expo SDK major version upgrades |
| API endpoint URLs | Android/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
- Always use rollouts for production — Start at 5–10% and increase gradually. Monitor error rates on the EAS dashboard before going to 100%.
- 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.
- 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.
- 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.
- 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.
- 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.
- Enable code signing for sensitive apps — If your app handles financial transactions, healthcare data, or enterprise workflows, code signing adds a critical security layer.
- 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.