◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-05-01Intermediate

EAS Update Published but Nothing Changes? Five Patterns That Quietly Break OTA Delivery in Rork

You ran eas update, the CLI showed a green Published, but your iPhone keeps loading the old code. Here are the five patterns I keep running into, plus a five-minute diagnostic flow you can use the next time OTA goes silent.

Rork584EAS Update7OTA7Expo219Troubleshooting43iOS116Android55

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 production

Your 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:

  1. Swipe the app away from the multitasker
  2. Open it once (this triggers the download)
  3. Swipe it away again
  4. 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 5

The 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:

  1. eas channel:list to confirm where you published
  2. eas update:list --branch <name> to confirm the latest publish is registered there
  3. Note the runtimeVersion of that publish
  4. Check the runtime version your test app is actually running
  5. Mismatch → revert your local app.json version to the live store value and republish
  6. Match → fully quit the app, open it twice, see if it lands
  7. 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.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-09-21
A build that stops with exit code 0 — the flag that silenced the log, and the check that let an empty file through
Two reports landed in the same week: eas build --local stopping with exit code 0 but produced no further output, and a zero-byte stub that never got embedded. Both came down to settings that reduce output. Here is how to audit your own scripts.
⬡ Dev Tools2026-10-15
When EXPO_PUBLIC_ Values Read Fine in Dev but Come Back undefined in Production: Telling the Spellings Apart
The API URL reads fine while developing and turns undefined once the app is built. The cause was how process.env was written. I lined up babel-preset-expo's output for dev and production and tabulated which spellings get replaced and which are left behind.
⬡ Dev Tools2026-10-07
Japanese Headings Won't Turn Bold on Android: How to Trace and Fix a fontWeight That Does Nothing
A troubleshooting note for Rork apps where headings look bold on iPhone but stay thin on Android. How to tell the symptoms apart, pick weights through fontFamily, and verify the fix.