Choosing a runtimeVersion policy for expo-updates OTA releases
expo-updates ships only JavaScript and assets. runtimeVersion is the gate deciding which bundles an installed binary accepts — and how to pick a policy.
17 May 2026, 07:33 UTC

A JavaScript-only fix goes out over the air. Most users get it and move on. A slice of users on an older build open the app and it crashes on launch, because the new bundle imports a native module their installed binary does not contain. expo-updates has no way to know that unless you tell it which bundles are allowed to run on which binaries. That gate is runtimeVersion, and it is the decision worth getting right before you enable over-the-air delivery.
What expo-updates actually ships
expo-updates delivers the JavaScript bundle and the assets that bundle references. It does not deliver native code, new permissions, new native modules, or changes to Info.plist and AndroidManifest.xml. Anything in that second list requires a new binary through EAS Build or a local Xcode/Android Studio build.
At runtime the client fetches a manifest from a URL, compares it with what is installed, downloads a newer bundle if one is offered, and applies it — typically on the next launch, or immediately if your code asks for a reload. The exact config keys and module API have shifted across SDK releases, so treat the names below as a starting point and confirm them against the reference for the SDK you have installed.
runtimeVersion is the compatibility contract
Every published update carries a runtime version string. An installed binary only accepts an update whose runtime version matches its own. If they differ, the client ignores the update and keeps running what it has — which is the safe outcome, and the reason a mismatch usually looks like "my update never arrived" rather than a crash.
Expo lets you derive that string with a policy:
| Policy | What it follows | Practical consequence |
|---|---|---|
sdkVersion | The Expo SDK version | Simple, but two native changes within one SDK are indistinguishable, so a bundle can land on a binary it was not built against |
nativeVersion | App version plus build number | You control it explicitly and must remember to bump it whenever native code changes |
appVersion | The user-facing version string | Same manual discipline, coarser granularity |
fingerprint | A hash of the native dependency set and native config | Moves automatically when native inputs change; safest for apps with native modules, at the cost of a rebuild whenever the hash moves |
Policy names and availability vary by SDK, so verify which ones your version supports before committing to one.
A worked example
This app.config.js enables updates with a fingerprint-derived runtime version. Replace the project URL with the one eas update:configure writes for your project.
export default {
expo: {
name: 'Field Notes',
slug: 'field-notes',
version: '2.4.0',
runtimeVersion: { policy: 'fingerprint' },
updates: {
enabled: true,
url: 'https://u.expo.dev/REPLACE-WITH-PROJECT-ID',
checkAutomatically: 'ON_LOAD',
fallbackToCacheTimeout: 0,
},
},
};
enabled— turns the update check on. Keep itfalsein builds you do not want checking.url— where the manifest lives. With EAS Update this is generated for you.checkAutomatically— when the client checks.ON_LOADchecks at launch; other values restrict checks to Wi-Fi or skip them entirely.fallbackToCacheTimeout— how many milliseconds the app waits for a manifest before launching the installed bundle.0never blocks launch, which is usually what you want on mobile networks.
To apply an update from app code, the expo-updates module exposes a check, fetch and reload sequence:
import * as Updates from 'expo-updates';
export async function applyPendingUpdate() {
if (!Updates.isEnabled) return 'updates-disabled';
try {
const check = await Updates.checkForUpdateAsync();
if (!check.isAvailable) return 'up-to-date';
const fetched = await Updates.fetchUpdateAsync();
if (fetched.isNew) {
await Updates.reloadAsync();
return 'reloaded';
}
return 'fetched-not-new';
} catch (error) {
return 'check-failed';
}
}
The try/catch is not decoration. On a metered or flaky connection the check can reject, and there is no retry policy you can rely on across SDK versions; decide in your own code whether to retry, back off, or silently try again next launch. Confirm the export names and return shapes against your SDK's expo-updates reference before shipping this — the module has gained hooks and constants over time, and the names above are the long-standing ones rather than a guarantee for every version.
Verifying the gate actually works
- From the project root, run
npx expo config --type publicand confirm the resolvedruntimeVersionandupdatesvalues are what you expect. Older CLI versions usedexpo diagnosticsfor a similar check; use whichever your CLI provides. - Run
npx expo-doctorto catch configuration and dependency mismatches before you build. - In a development build, log the runtime version and update ID the client reports, then publish an update and confirm the logged values change after a reload. If the runtime version the client reports does not match the one in the published manifest, the update is silently skipped — that mismatch is the first thing to hunt.
- Fetch the manifest URL directly with a browser or
curland confirm it returns JSON containing aruntimeVersionfield. A 404 or an HTML error page is a hosting problem, not a client problem.
The trade-off, and how to undo a bad update
Fingerprint is the safest policy and the most demanding: every native dependency or config change moves the hash, so you must rebuild and resubmit before OTA can reach users again. sdkVersion is easier to live with but lets a bundle reach binaries it was never tested against. Pick based on how often your native surface changes, not on which option is newest.
Publishing an update changes what users run, so plan the reversal. The reliable route is to publish a new update built from the previous commit; some EAS CLI versions also expose a rollback or republish command, which you can check with eas update --help for your installed version. Either way, a user who already downloaded the bad bundle may execute it once before the next check picks up the replacement, so keep a native fallback path for anything that can hard-crash at startup.
Start by choosing a runtime version policy deliberately, log the runtime version and update ID in every build, and verify the manifest the client actually sees before you rely on over-the-air delivery for a fix that matters.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.