DEV Community

Mason Roy
Mason Roy

Posted on

Expo OTA Updates Are a Loaded Gun. Here's the Safety.

TL;DR

  • runtimeVersion is the only thing standing between an OTA update and a crash loop. Use the fingerprint policy, not appVersion.
  • An OTA update can't change native code. If you touched ios/, android/, a config plugin, or a package with native code, ship a store build. No exceptions.
  • Default checkOnLaunch + fallbackToCacheTimeout: 0 means users get the update next launch, not this one. Decide if that's what you want.
  • Test updates on a preview build with a dedicated channel. expo-updates is disabled in dev clients.
  • Rollback isn't a button. It's republishing a known-good update. Know the command before you need it.

OTA updates are the best thing about Expo and the fastest way I've shipped a broken app to 100% of users in under a minute.

That's not a contradiction. eas update is a loaded gun. It's incredibly useful. It also has no safety on by default. Here's the one I've built up over a couple of years of using it in production, one incident at a time.

1. The runtime version mismatch (aka the crash loop)

This is the big one. Every OTA update is tagged with a runtimeVersion. Every native build has one too. Expo only delivers an update to a build with a matching runtime version. If you get this wrong in the permissive direction — an update that matches a build it shouldn't — you ship JS that calls a native module the build doesn't have, and the app crashes on launch. Forever. There's no "next update fixes it," because the app never runs long enough to fetch it.

The default in a lot of older projects:

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

appVersion ties the runtime to the version string in app.json. Sounds fine. It means: as long as you don't bump version, every build is considered compatible. Add a native dependency, forget to bump, publish an update — crash loop for anyone on the old build.

The fix is to let Expo compute it from what's actually native in the project:

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

fingerprint hashes the native project — dependencies with native code, config plugins, ios/ and android/ if you have them. Change any of it and the fingerprint changes, so the next eas update simply won't be delivered to old builds. You can check what you're about to ship against:

npx expo-updates fingerprint:generate
# or compare against a specific build
eas fingerprint:compare
Enter fullscreen mode Exit fullscreen mode

If the fingerprint changed and you didn't expect it to, that's your signal that a store build is required, not an update.

2. Shipping native changes over the air

Related, but a different mistake: knowing the rule and getting it wrong anyway because the change didn't look native.

Things that are native and require a new build, that people routinely try to OTA:

✗ Adding any package with an ios/ or android/ directory
✗ Changing a config plugin's options (permissions text, deep link schemes, etc.)
✗ Bumping Expo SDK version
✗ Changing app icon, splash screen, or bundle identifier
✗ Enabling/disabling the new architecture
✗ Updating expo-updates itself
Enter fullscreen mode Exit fullscreen mode

Things that are JS and are fine to OTA:

✓ Any .ts / .tsx / .js change
✓ Assets imported from JS (images via require(), fonts loaded via expo-font)
✓ Changes to app.json fields that are read at runtime (extra, updates config)
Enter fullscreen mode Exit fullscreen mode

The fingerprint policy catches all of the first list. But you should know the list anyway, because you'll want to plan a store release before you're blocked by one.

3. Publishing to the wrong channel

Channels are how you point a build at a stream of updates. Branches are where updates live. A build says "I listen to channel production." A channel is mapped to a branch. eas update publishes to a branch.

// eas.json
{
  "build": {
    "preview": {
      "channel": "preview",
      "distribution": "internal"
    },
    "production": {
      "channel": "production"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The mistake is treating the branch name as a free-form string:

# This creates a branch called "fix-login" that no build listens to.
# Nothing happens. You think the update is out. It isn't.
eas update --branch fix-login --message "fix login"
Enter fullscreen mode Exit fullscreen mode

What I do now, without exception:

# Preview first — this hits the internal build the team has installed
eas update --channel preview --message "fix: login race on cold start"

# Once someone on the team confirms it on-device:
eas update --channel production --message "fix: login race on cold start"
Enter fullscreen mode Exit fullscreen mode

Using --channel instead of --branch means the update goes wherever that channel is currently pointed, which is what you actually mean. Check the mapping if in doubt:

eas channel:view production
Enter fullscreen mode Exit fullscreen mode

4. Assuming the user has the update

The default update behaviour:

{
  "expo": {
    "updates": {
      "enabled": true,
      "checkAutomatically": "ON_LOAD",
      "fallbackToCacheTimeout": 0
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

fallbackToCacheTimeout: 0 means: on launch, immediately load the cached bundle, check for an update in the background, download it if there is one, and apply it next time the app launches. Users are always one launch behind.

That's the right default for a consumer app. It's the wrong default when you're fixing something urgent and the user opened the app to hit the bug. Two ways to close the gap.

Option A — prompt and reload:

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

export function useUpdatePrompt() {
  useEffect(() => {
    if (__DEV__) return; // expo-updates is a no-op in dev

    (async () => {
      const { isAvailable } = await Updates.checkForUpdateAsync();
      if (!isAvailable) return;

      await Updates.fetchUpdateAsync();
      Alert.alert('Update ready', 'Restart to apply the latest version?', [
        { text: 'Later', style: 'cancel' },
        { text: 'Restart', onPress: () => Updates.reloadAsync() },
      ]);
    })().catch(() => {}); // never let an update check crash the app
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

Option B — apply on foreground:

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

export function useApplyUpdateOnForeground() {
  useEffect(() => {
    if (__DEV__) return;

    const sub = AppState.addEventListener('change', async (state) => {
      if (state !== 'active') return;
      const { isAvailable } = await Updates.checkForUpdateAsync().catch(() => ({ isAvailable: false }));
      if (isAvailable) {
        await Updates.fetchUpdateAsync();
        await Updates.reloadAsync(); // hard reload — only do this if the user isn't mid-form
      }
    });
    return () => sub.remove();
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

Option B is aggressive. Reloading while someone's halfway through a checkout is worse than the bug you're fixing. I use A by default and B only behind a "critical" flag in the update's metadata.

Also: __DEV__ early-return matters. expo-updates is disabled in development builds, and checkForUpdateAsync throws. Wrap it or skip it.

5. Not knowing how to roll back

There's no undo. Rollback means publishing a new update whose contents are the old update.

# Find the last known-good update
eas update:list --branch production

# Republish it. This creates a new update with the old bundle.
eas update:republish --branch production --group <update-group-id>
Enter fullscreen mode Exit fullscreen mode

That's it, but the key detail is that you should run update:list before the incident, so you know what a healthy list looks like and can spot the bad group in seconds instead of minutes. I keep the last three good group IDs in a pinned message in the team chat. Low-tech, works.

If the bad update caused a crash loop (mistake #1), rollback won't save the users already affected — their app can't fetch anything. That's why #1 is first.

The config I start every project with

Pulling it together:

// app.json
{
  "expo": {
    "runtimeVersion": { "policy": "fingerprint" },
    "updates": {
      "enabled": true,
      "checkAutomatically": "ON_LOAD",
      "fallbackToCacheTimeout": 0,
      "url": "https://u.expo.dev/<project-id>"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// eas.json
{
  "build": {
    "development": { "developmentClient": true, "distribution": "internal" },
    "preview": { "channel": "preview", "distribution": "internal" },
    "production": { "channel": "production" }
  }
}
Enter fullscreen mode Exit fullscreen mode

Plus the useUpdatePrompt hook at the root, and a scripts/ota-release.sh that runs the fingerprint compare, refuses to publish if it changed, publishes to preview, and prints the production command for a human to run. That last step being manual is on purpose.

If you'd rather not assemble that from scratch, the RapidNative boilerplate ships with the fingerprint policy, channel config, and update hook already wired — it's the setup above, minus the incidents that taught me each line.


What's your OTA horror story? Drop it in the comments — I'm collecting them, and I'm sure there's a #6 I haven't been burned by yet.

Top comments (0)