TL;DR
- An OTA update only reaches builds whose
runtimeVersionexactly matches the update's. - Hardcoding
runtimeVersion: "1.0.0"and forgetting to bump it after a native change will crash users. Use thefingerprintpolicy. - 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
previewchannel first, then promote the same update toproduction.
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:
-
Channel: which builds listen (e.g.
production) - Platform: ios / android
- 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"
}
}
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"
}
}
}
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
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);
}
})();
}, []);
}
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
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
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
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,
});
-
updateIdisnull→ 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)