DEV Community

Mason Roy
Mason Roy

Posted on

Your Expo OTA Update Shipped. Half Your Users Never Got It. Here's Why.

TL;DR

  • An OTA update only reaches builds whose runtimeVersion exactly matches the update's.
  • Hardcoding runtimeVersion: "1.0.0" and forgetting to bump it after a native change will crash users. Use the fingerprint policy.
  • By default, updates download on launch but apply on the next cold start. Many "it didn't work" bugs are really "the app wasn't restarted."
  • Always ship to a preview channel first, then promote the same update to production.

I pushed a hotfix with eas update, watched the dashboard say "Published," and went to lunch. Two hours later the bug reports were still coming in. Not a crash, just the old behavior, as if the update never existed.

For a chunk of users it didn't. Here's what I learned.

How EAS Update decides who gets what

Every update is tagged with three things:

  1. Channel: which builds listen (e.g. production)
  2. Platform: ios / android
  3. Runtime version: the native "shape" the JS bundle expects

A build fetches an update only if all three match. The runtime version is the one that bites, because JS that calls a native module the binary doesn't have causes a crash on launch.

Mistake #1: A hardcoded runtime version

This is in a lot of tutorials:

{
  "expo": {
    "runtimeVersion": "1.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

It works until you add a native library like expo-camera, ship a new binary, and forget to bump the string. Now your new JS (which imports the camera) gets served to old binaries (which don't have it). The result is a red screen.

The fix is to let Expo hash your native layer for you:

{
  "expo": {
    "runtimeVersion": {
      "policy": "fingerprint"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With fingerprint, any native change produces a new runtime version automatically. Old binaries keep receiving only compatible JS updates.

Check what a build will report before you publish:

npx expo-updates fingerprint:generate --platform ios
Enter fullscreen mode Exit fullscreen mode

Mistake #2: Expecting the update to apply instantly

The default is checkAutomatically: "ON_LOAD". The app downloads the update in the background and runs it on the next launch. Users who background the app instead of killing it can sit on the old bundle for days.

For critical fixes, check and reload yourself:

import * as Updates from 'expo-updates';
import { useEffect } from 'react';

export function useCriticalUpdate() {
  useEffect(() => {
    if (__DEV__) return; // updates are disabled in dev

    (async () => {
      try {
        const result = await Updates.checkForUpdateAsync();
        if (result.isAvailable) {
          await Updates.fetchUpdateAsync();
          await Updates.reloadAsync(); // applies immediately
        }
      } catch (e) {
        // Network failures are normal; don't block the app
        console.warn('Update check failed', e);
      }
    })();
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

Don't reload without warning on every update; users lose whatever screen they were on. A good pattern: reload immediately only when update metadata flags it as critical, otherwise show a "Restart to update" banner.

You can also react to the built-in state hook:

const { isUpdatePending } = Updates.useUpdates();
// show a banner when isUpdatePending === true
Enter fullscreen mode Exit fullscreen mode

Mistake #3: Publishing straight to production

eas update --branch production from your laptop is the mobile version of git push --force to main. Do this instead:

# 1. Ship to preview builds used by your team
eas update --branch preview --message "Fix checkout total rounding"

# 2. Test on a real preview build. Then promote the *same* update:
eas update:republish --branch preview --destination-branch production
Enter fullscreen mode Exit fullscreen mode

Republishing promotes the exact bundle you tested instead of rebuilding it, so there are no "works on preview" surprises.

And if production still breaks:

eas update:rollback
Enter fullscreen mode Exit fullscreen mode

Debugging checklist when users "don't get" an update

console.log({
  channel: Updates.channel,
  runtimeVersion: Updates.runtimeVersion,
  updateId: Updates.updateId, // null = running the embedded bundle
  isEmbeddedLaunch: Updates.isEmbeddedLaunch,
});
Enter fullscreen mode Exit fullscreen mode
  • updateId is null → the app never fetched anything. Check the channel name.
  • Runtime version differs from the dashboard → the update is incompatible with this binary, which is working as intended. Ship a new build.
  • IDs match but behavior is old → you're probably looking at a cached screen or state, not a stale bundle.

Put this behind a hidden debug screen. You'll be glad you did when support tickets come in.

Don't hand-wire all of this every project

The update hook, the channel setup, and the fingerprint config are the same boilerplate in every Expo app I start. These days I generate the base app with RapidNative, which outputs a standard Expo + React Native codebase I can drop this config into. That leaves time for the parts that are actually specific to the app.


Your turn: what's the worst OTA update incident you've shipped? Drop it in the comments. I'll trade you my "pushed to production on a Friday" story.

Top comments (0)