React Native OTA Updates with Expo EAS Update
Ship JavaScript fixes to production in minutes: runtime versions, channels, safe rollouts and common mistakes.
Ship a fix to production in minutes instead of waiting days for app store review. Over-the-air (OTA) updates let a React Native or Expo app download a new JavaScript bundle and assets after it is already installed. This guide covers what OTA can and cannot change, how Expo's EAS Update is wired together, a safe release workflow, and the mistakes that cause crashes in production.
What OTA can and cannot update
An app binary has two layers. OTA only replaces the top one.
| Layer | Examples | Updated by OTA? |
|---|---|---|
| JavaScript and assets | Screens, business logic, styles, images, fonts bundled with JS | Yes |
| Native code | New native modules, permissions, Info.plist / AndroidManifest.xml, SDK upgrades, app icon, splash | No, needs a new store build |
If a change touches anything in the second row, ship a new binary. OTA is for fast, low-risk JavaScript changes.
How EAS Update fits together
Four ideas decide who receives which update.
- Runtime version: a label for the native layer of a build. An update is only delivered to builds with the same runtime version, so JavaScript never runs against native code it was not written for.
- Branch: a named stream of updates, like a git branch.
- Channel: a name embedded in each build (for example
productionorpreview). A channel points to a branch. - Update: one published JavaScript bundle plus assets on a branch.
Because the channel is baked into the build and points to a branch, you can change which updates a build receives without shipping a new binary.
Setup
npx expo install expo-updates
eas update:configure
eas update:configure adds the update URL and a runtime version policy to your app config. Pick the runtime policy deliberately:
{
"expo": {
"runtimeVersion": { "policy": "fingerprint" },
"updates": { "url": "https://u.expo.dev/<your-project-id>" }
}
}
| Policy | Runtime version changes when | Good for |
|---|---|---|
appVersion | You bump the app version | Simple apps with disciplined versioning |
nativeVersion | Native version or build number changes | Teams that already bump build numbers |
fingerprint | The native project actually changes | Safest default: it detects native changes for you |
sdkVersion | Expo SDK changes | Managed apps that only change native code via SDK upgrades |
Then map build profiles to channels in eas.json:
{
"build": {
"preview": { "channel": "preview", "distribution": "internal" },
"production": { "channel": "production" }
}
}
Publishing updates
# Publish to the branch linked to a channel
eas update --channel production --message "Fix checkout total rounding"
# Publish to an explicit branch
eas update --branch staging --message "Try new onboarding"
# In CI: use the current git branch name as the update branch
eas update --auto
A good message states the user-visible change. You will read these when you need to find or roll back an update.
Controlling when users get the update
By default the app checks for an update at launch, downloads it in the background and applies it on the next cold start. That is safe but slow to reach users. When you want more control, use the expo-updates API:
import * as Updates from "expo-updates";
import { useEffect } from "react";
export function useApplyUpdates() {
const { isUpdateAvailable, isUpdatePending } = Updates.useUpdates();
useEffect(() => {
if (isUpdateAvailable) Updates.fetchUpdateAsync();
}, [isUpdateAvailable]);
useEffect(() => {
if (isUpdatePending) Updates.reloadAsync(); // restart into the new bundle
}, [isUpdatePending]);
}
Reloading mid-task is jarring. Prefer applying on a natural boundary: app foreground, after a form is submitted, or behind an "Update ready, restart" prompt for large changes.
A safe release workflow
- Develop and test on a development or preview build.
- Publish to
previewand test on a real device that has the preview build. - Promote the same change to
productionwith a clear message. - Watch crash and error reporting for the first hour. An update that crashes at launch can affect every user on that channel.
- Roll back fast if needed by republishing the last good update to the branch, or rolling back to the embedded bundle. Check
eas update --helpand the EAS docs for the current rollback and gradual-rollout commands.
For high-risk changes, use a gradual rollout so only a percentage of users receive the update at first, then widen it.
Store policy
Apple and Google allow OTA updates of JavaScript and assets as long as you do not change the app's primary purpose or use updates to bypass store review. Keep OTA for fixes and iterative improvements, and ship anything that materially changes functionality through a normal review.
Common mistakes
- Native change shipped as OTA. A new native module in an update crashes builds that do not contain it. Use the
fingerprintpolicy so the runtime version changes automatically. - Wrong channel. Publishing to
productionwhile testing means real users get unfinished work. Make channel names part of your team vocabulary. - Assuming environment variables update. Variables inlined at build time stay in the binary. Values read when you run
eas updateare baked into that update. Know which is which. - No way to tell what is running. Show
Updates.updateId,Updates.channelandUpdates.runtimeVersionon an internal debug screen. - Forgetting old binaries. Users on an old store version keep their old runtime version, so they only receive updates published for it.
Bare React Native (without Expo)
Bare React Native apps can use expo-updates too, with extra native setup. Microsoft retired App Center, including hosted CodePush, in March 2025, so plan on EAS Update, a self-hosted update server that implements the Expo Updates protocol, or another maintained OTA provider.
Checklist
- Runtime version policy chosen (prefer
fingerprint) - Channels defined per build profile
- Preview channel used before production
- Update message describes the user-visible change
- Crash reporting watched after each production update
- Rollback steps written down and tried once
- Debug screen shows update id, channel and runtime version