The whole point of EAS Update — shipping fixes without resubmitting to the App Store — is fantastic when it works, and exhausting when it does not. The frustrating thing is that EAS Update tends to fail silently: the CLI prints a cheerful "Published" line, Sentry stays quiet, and yet your test device keeps running last week's bundle.
I have spent more late-night hours than I care to admit on this, and after enough rounds I have learned that the failures are almost never random. They cluster into about five recurring patterns, all caused by a small mismatch between what you published and what the installed app is actually allowed to receive. This article walks through those five patterns in the order I check them, and ends with a five-minute diagnostic flow you can run on a tired Friday evening.
Step Zero: Confirm Which Channel You Are Actually Publishing To
Roughly nine out of ten OTA disasters come down to the channel you published to does not match the channel the installed binary is listening to. So before anything else, line those two up.
# Show the channels that exist in your project
eas channel:list
# Show recent updates on a specific branch
eas update:list --branch productionYour eas.json likely looks something like this:
{
"build": {
"production": {
"channel": "production",
"autoIncrement": true
},
"preview": {
"channel": "preview",
"distribution": "internal"
}
}
}If your TestFlight build came from the production profile but you ran eas update --branch preview, the production binary will never see that update. Your binary only listens to the channel it was compiled with. Decide explicitly: am I testing on a preview build or a production build today? Almost every "it does not work" report I get answers itself once that question is answered.
Pattern 1: Mismatched Runtime Version
This is the one I trip over most often.
EAS Update is designed around the idea that JavaScript-only changes can be hot-shipped, but anything that touches native code requires a full rebuild. To enforce that, every published update is tagged with a runtimeVersion, and the installed app will only accept updates whose runtime version exactly matches its own. If they differ — even by a single digit — the device silently ignores your publish.
Check your strategy in app.json:
{
"expo": {
"runtimeVersion": {
"policy": "appVersion"
}
}
}With the appVersion policy, every change to expo.version produces a new runtime version. So if version 1.2.0 is live in the App Store and you bumped your local app.json to 1.3.0 before running eas update, the 1.2.0 users in the wild are not eligible for that update. They will keep running 1.2.0 forever.
The fix is unglamorous but reliable: keep your local app.json version pinned to whatever is currently in the store while you publish OTA fixes. Bump it only when you are about to ship a new binary.
Pattern 2: Misconfigured expo-updates on the Client
Three things have to line up at build time: the updates.url, the runtimeVersion strategy, and the channel ID baked into the iOS / Android binary. Rork-generated projects have these wired up correctly out of the box, but it is easy to bend something while debugging and forget to undo it.
{
"expo": {
"updates": {
"url": "https://u.expo.dev/your-project-id",
"enabled": true,
"checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0
},
"runtimeVersion": { "policy": "appVersion" }
}
}The two settings I see go wrong most often: enabled: false (sometimes set during a debug session and forgotten), and checkAutomatically: ON_ERROR_RECOVERY (which means the app only checks for updates after a crash). I once shipped a production build with enabled: false and lost two days to it before noticing.
Pattern 3: Users Did Not Restart the App Twice
This one is by spec, but it surprises almost everyone the first time.
The default behavior of expo-updates is: on launch, check for a new bundle in the background; if found, download it and apply it on the next launch. So if your tester is currently running the app, your publish does nothing for them right now. They have to fully quit the app — not background it, actually swipe it away — and reopen it. Twice.
If you cannot reproduce on your own device, do this routine first:
- Swipe the app away from the multitasker
- Open it once (this triggers the download)
- Swipe it away again
- Open it again (this is when the new bundle actually runs)
If you need updates to apply immediately for a critical fix, do the check-and-reload yourself in code:
import * as Updates from "expo-updates";
import { useEffect } from "react";
import { Alert } from "react-native";
export function useImmediateUpdate() {
useEffect(() => {
if (__DEV__) return; // skip in dev/Expo Go
(async () => {
try {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
Alert.alert(
"Update available",
"The app will reload to apply the update.",
[{ text: "OK", onPress: () => Updates.reloadAsync() }]
);
}
} catch (e) {
// Network errors are fine — the next launch will retry.
console.warn("[updates]", e);
}
})();
}, []);
}The expected behavior: right after you publish, when the user reopens the app, they see a one-tap dialog and the bundle hot-swaps. Do not skip the __DEV__ guard. Without it, you will get nonstop "no updates available" errors during local development against Rork or Expo Go.
Pattern 4: You Are Publishing to the Wrong Build Profile
Updates published with eas update --branch development reach development builds only. Updates on the preview branch reach internal-distribution preview builds only. Obvious in writing, but it bites people who hand around TestFlight builds compiled from a production profile and then publish fixes to development.
Cross-check the build profile of your test device:
eas build:list --platform ios --limit 5The profile column in the output should match the branch name you pass to eas update --branch <name>. If you are not sure which TestFlight build is from which profile, this list is the source of truth.
Pattern 5: Stale Cached Bundle on the Device
If everything above checks out and updates still are not landing, it is time for the nuclear option.
expo-updates boots from a cached bundle while it fetches the next one in the background. Under certain conditions — corrupted downloads, abrupt force-quits during a fetch — the cache can hold onto an old bundle in a way that no amount of reloadAsync will dislodge.
The clean diagnostic step is to uninstall the app and reinstall it from TestFlight or the Store. That returns the device to "embedded bundle only" — exactly what a fresh user has on day one.
- Reinstall and the new content shows up → you had a client-side cache issue. The fix usually arrives the next time the app is opened normally.
- Reinstall and you still see the old code → your publish never reached this binary. Loop back to patterns 1 through 4.
A Five-Minute Diagnostic Flow
When you are panicking, here is the shortest path to a real diagnosis:
eas channel:listto confirm where you publishedeas update:list --branch <name>to confirm the latest publish is registered there- Note the
runtimeVersionof that publish - Check the runtime version your test app is actually running
- Mismatch → revert your local
app.jsonversion to the live store value and republish - Match → fully quit the app, open it twice, see if it lands
- Still no → uninstall, reinstall from store, retry
The fastest way to read the device's own state is to expose it inside the app. I keep a tiny debug component on a hidden screen in every project:
import * as Updates from "expo-updates";
import { Text, View } from "react-native";
export function UpdateDebugInfo() {
return (
<View style={{ padding: 16 }}>
<Text>Runtime: {Updates.runtimeVersion}</Text>
<Text>Channel: {Updates.channel}</Text>
<Text>Update ID: {Updates.updateId ?? "(embedded)"}</Text>
<Text>Created: {Updates.createdAt?.toISOString() ?? "-"}</Text>
</View>
);
}Four lines and you instantly know which bundle the user is on. In production, hide it behind a long-press gesture in the settings screen — that has saved me real money in support time.
A Note on Why These Failures Look Identical
Something I find worth saying out loud: the five patterns above all look identical from the user's seat. The user opens the app, sees old behavior, and reports "the update did not work". You have no way to distinguish a runtime mismatch from a stale cache from a wrong-channel publish without digging into the device's own update state. That is exactly why the UpdateDebugInfo component is so useful. The cost of building it is fifteen minutes; the cost of not having it, the first time a paying customer reports a stuck build, is significantly more.
The deeper lesson — and the one that took me longest to internalize — is that OTA reliability is mostly an observability problem, not a coding problem. The CLI tells you what you published. The user's device knows what it actually downloaded. Until you wire those two ends together, every reported "stuck" is going to be a guessing game.
Related Reading
If you want to step back and understand the whole OTA story rather than just the failure modes, start with the Rork EAS Update / OTA delivery guide. Once you have publishing under control, the natural next step is automating it via CI: Building a CI/CD pipeline with GitHub Actions and EAS Build. And if your problem is not "OTA does not land" but "the build itself does not even compile", jump over to Patterns for fixing React Native build errors in Rork first — there is no point publishing OTA on a binary that cannot be built.
What To Do Tonight
OTA failures are almost always configuration mismatches, not code bugs. So before you start firing off eas update in panic, slow down for sixty seconds and run eas channel:list followed by eas update:list --branch <name>. Most of the time, that single check tells you exactly which lever is misaligned.
The one concrete thing I would do tonight: drop that UpdateDebugInfo component into a hidden screen of your current project. The next time someone reports "my version is stuck", you will be able to read their actual runtime, channel, and update ID in seconds — and that single piece of information collapses most of these five patterns down to a one-line answer.