The line: JavaScript travels, native does not
An installed app is two layers. The native binary — compiled Swift and Kotlin, your linked libraries, your permission strings, your icon — is fixed the moment you build it. On top sits a JavaScript bundle plus assets, and thatis what an update replaces. Every question about “can I OTA this?” reduces to which layer the change lives in.
| Change | Ships how | Why |
|---|---|---|
| Fix a crash in a screen | ✅ OTA | Pure JavaScript |
| Change copy, colours, layout | ✅ OTA | Pure JavaScript |
| Point at a new API endpoint | ✅ OTA | Value inlined in the bundle |
| Add or replace a bundled image | ✅ OTA | Assets ship with the update |
| Add a JS-only npm package | ✅ OTA | No native code to link |
| Add expo-camera, or any native module | ❌ New build | Native code must be compiled in |
| Add or change a permission string | ❌ New build | Lives in Info.plist / AndroidManifest |
| Change the app icon or splash image | ❌ New build | Native resources, set at build time |
| Upgrade the Expo SDK | ❌ New build | Changes the native runtime |
| Change the store listing or app name | ❌ New submission | Store metadata, not your bundle |
The dangerous cases are the mixed ones. Adding a library that is mostly JavaScript but registers a native module will publish happily and then crash on launch for every user who receives it, because the native side it calls into does not exist in their installed binary. That is exactly what runtime versions are for.
Runtime version: the compatibility contract
Every build carries a runtimeVersion, and every published update carries one too. A device only accepts an update whose runtime version matches its own — exactly, as a string. That is the entire safety mechanism, and it is also the number-one reason an update appears to vanish.
// app.json — let Expo compute it from the native layer
{
"expo": {
"runtimeVersion": { "policy": "fingerprint" },
"updates": { "url": "https://u.expo.dev/YOUR-PROJECT-ID" }
}
}The fingerprint policy hashes your native dependencies and config, so the runtime version changes automatically the moment you add a native module — which means an incompatible update simply is not offered to older builds instead of crashing them. It is the policy you want unless you have a specific reason otherwise.
The alternative, appVersion, ties compatibility to your version string and puts the discipline on you: bump the version whenever the native layer changes, or you will ship an update into builds that cannot run it. A hardcoded string works too, and is the most error-prone of the three for the same reason.
Channels and branches, in one paragraph
A branch is a stream of updates. A channelis a label compiled into a build. A channel points to a branch. A build asks EAS “what is current on my channel?”, EAS follows the pointer to a branch, and hands back the newest update on it that matches the build’s runtime version.
// eas.json
{
"build": {
"preview": { "distribution": "internal", "channel": "preview" },
"production": { "channel": "production" }
}
}eas update:configure # one-time setup eas update --branch production --message "Fix crash on empty cart" eas branch:list # what streams exist eas channel:view production # which branch this channel points at eas channel:edit production --branch hotfix-2026-08 # repoint without a new build
That last command is why the indirection exists. A channel baked into a binary is permanent; where it points is not. You can move every production install onto a hotfix branch and back again without anyone downloading a new app.
The second-launch rule
The default behaviour catches everyone: on launch, the app checks for an update and downloads it in the background while the current bundle keeps running. The new code is applied on the next launch. So you publish, open the app, see nothing, and conclude it is broken — when it worked and you were one cold start early.
If a fix is urgent enough that waiting for a natural second launch is too slow, drive it yourself:
import * as Updates from 'expo-updates';
export async function applyUpdateIfAny() {
if (__DEV__) return; // inert in dev anyway
try {
const { isAvailable } = await Updates.checkForUpdateAsync();
if (!isAvailable) return;
await Updates.fetchUpdateAsync();
await Updates.reloadAsync(); // restarts into the new bundle
} catch {
// Offline, or the server is unreachable. Never block launch on this.
}
}Two rules if you do this. Never await it before your first render — a user on a bad connection gets a frozen splash. And never call reloadAsync mid-session without asking; restarting the app under someone’s fingers loses their unsaved work. The polite version checks on resume, and shows a small “Update ready — restart” prompt.
Rolling back
The thing to internalise before you need it: a rollback is just another update. There is no remote kill switch that reaches a device which has already downloaded and applied bad code — it gets the fix on its next check, same as any other update. Publish the correction immediately, then work out what happened.
eas update:list --branch production # find the last good update ID eas update:republish --group <GROUP_ID> # re-publish it as the newest update # Nothing good on the branch? Send clients back to the bundle inside the binary: eas update:roll-back-to-embedded --branch production
The practical defence is to not need this often: publish to a preview branch first, install that build on a real device, and confirm the update lands before touching the production channel. OTA speed is a fine reason to ship fixes fast; it is a bad reason to skip the step where a human opens the app.
Debugging “the update did not arrive”
Work down this list in order — it is roughly the frequency distribution of the actual cause:
- Runtime version mismatch.Compare the update’s runtime version in
eas update:listagainst the build’s. Different strings mean the update is invisible to that build, by design and without any error. - Wrong channel. The build was made before you added
channelto its profile, so it subscribes to nothing. Check witheas channel:view. - First launch after publishing. Close the app fully and reopen it. This is the answer more often than the previous two combined.
- Expo Go or a dev server. Updates are disabled in both. Test on a preview or production build.
- Environment values are stale. Bundle-inlined values are resolved when the update is built, so publishing from a shell with different env vars ships different config than you expect — see Expo environment variables.
To make this diagnosable in production, surface the identity in a debug or settings screen: Updates.updateId, Updates.runtimeVersion, Updates.channel and Updates.isEmbeddedLaunch. When a user says “still broken,” one screenshot tells you whether they are running your fix.
Where the store still gets a say
Apple permits over-the-air code updates that do not change your app’s primary purpose or add undisclosed functionality — guideline 3.3.2 — and in practice bug fixes, copy, and UI changes are uncontroversial. Google Play’s policy is comparable in spirit. What gets people in trouble is using OTA to add a feature that would have failed review, or to flip an app into something different after approval. Do not.
And keep the version story honest: OTA updates do not change the version number users see in the store. When a release matters to users, ship a real build. The EAS Submit guide covers that path end to end.
Have the pipeline before you have the app
OTA only helps if the project is a real Expo project with EAS configured — which is the part most builders skip. Describe your app at shipnative.dev and you get a genuine React Native app you can preview on your phone and export as a complete Expo project, EAS config included — so the day you need a ten-minute hotfix, the pipeline is already there.