Shipping JavaScript Fixes Without the App Store: Expo Updates and the Runtime Version Gate
Expo Updates lets you ship JavaScript fixes in minutes instead of waiting for store review — but only if you treat the runtime version as the compatibility contract it is.
30 Dec 2025, 14:30 UTC

You shipped a typo in your checkout screen's error message. In a traditional React Native app, fixing it means a new binary, a store review, and days of waiting. With Expo Updates, the same fix can reach users in minutes — but only if you understand the one mechanism that keeps it safe: the runtime version.
The thesis of this piece is simple: Expo's over-the-air (OTA) updates are genuinely useful for JavaScript-only changes, and the runtime version is the contract that prevents those updates from breaking apps whose native layer has moved on.
What an OTA update actually is
An Expo app ships with a JavaScript bundle embedded in the native binary. When you publish an update — via eas update or the older expo publish — Expo uploads a new JS bundle and updates the manifest its servers hand to clients. On the next app launch, the device checks the manifest, downloads the new bundle if one is available, and runs it. No store review, no new binary.
The hard boundary: OTA updates can only change JavaScript, TypeScript, and assets bundled with the JS. Anything native — a new Expo module, a change to AndroidManifest.xml or Info.plist, a linked native library — requires a fresh build and store submission. This is a platform constraint, not an Expo limitation.
The runtime version is your compatibility gate
Here's the failure mode the runtime version exists to prevent. You add a native module in version 2.0 of your binary and write JS that calls it. A user still on the 1.0 binary fetches your new JS bundle over the air. The JS calls a native method that doesn't exist, and the app crashes on launch — possibly every launch, since the bad bundle is now cached.
The runtime version, declared in app.json or app.config.js, stops this. Clients only apply an update whose runtime version matches the one baked into their binary. Bump the runtime version whenever native code changes, and old binaries simply won't receive incompatible bundles — they keep running the JS they were built with.
// app.json
{
"expo": {
"runtimeVersion": "1.1.0",
"updates": {
"url": "https://u.expo.dev/your-project-id"
}
}
}A practical rule: treat the runtime version like an API version for your native layer. Any change to native dependencies, Expo SDK upgrades, or config plugin output means a bump. Pure JS work means you can leave it alone.
A worked example: shipping the typo fix
Assume an EAS-managed project with eas-cli installed and a production build already distributed. Run these from your project root, with an authenticated EAS account (eas login):
- Fix the string in your JS and commit.
- Run
eas update --branch production --message "Fix checkout error copy". This builds the JS bundle locally and uploads it; no native compilation happens. - Verify in the EAS dashboard that the update landed on the production branch with the expected runtime version.
- On a test device with the production binary installed, fully close and relaunch the app. The update downloads on launch; depending on your
updates.fallbackToCacheTimeoutconfiguration, it may apply on that launch or the next one.
To confirm the update applied rather than the cached bundle, surface something observable — bump a visible version string in the JS, or check Updates.updateId from expo-updates in a debug screen. Don't assume a relaunch means a fetch; the client checks on its own schedule.
If the fix turns out to be bad, rollback is republishing: point the production branch back at the previous update from the EAS dashboard or CLI. Devices pick up the reverted bundle on their next check. That's the real operational win — the blast radius of a JS mistake shrinks from "wait for Apple review" to "republish and wait for relaunches."
Trade-offs worth knowing before you rely on this
Offline users lag. Updates require connectivity. A user on a flaky network or behind a restrictive firewall runs the cached bundle indefinitely. Never design a feature that assumes all clients have the latest JS.
The gate only works if you respect it. Forgetting to bump the runtime version after a native change is the classic self-inflicted outage. Some teams derive the runtime version automatically from the native dependency set (Expo supports policy-based runtime versions such as appVersion or fingerprint-based policies in current SDKs) to remove the human step — worth evaluating, but verify behavior against your SDK version before adopting.
Staged rollouts need discipline. Branches and channels let you publish to a preview audience first. Use them. Publishing straight to production for anything riskier than copy changes is how a five-minute fix becomes a five-hour incident.
Where to start
Create a scratch project, publish a trivial JS change, and watch it appear on relaunch without a rebuild. Then change the runtime version and confirm the old binary ignores the new bundle. Those two experiments take an afternoon and teach you more than any amount of documentation — because the day you need OTA updates for real, you need the mental model, not just the command.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.