Guide Explains Shipping Expo App Fixes With Over-The-Air Updates
Marco explains how to use Expo and EAS Update to push JavaScript changes to React Native apps without App Store review, detailing the fallbackToCacheTimeout setting and which changes still require a full store build.
Original post · 6 min read
X ArticleApple Review Takes Days: Ship Fixes Instantly with Over-The-Air Updates
You find a bug. A typo on the paywall, a broken button, a copy fix that would take you 30 seconds to write. Then you remember: shipping it means a new build, a new submission, and days of waiting on Apple review.
That's the problem OTA (over-the-air) updates solve. Push a JS change, and it's live on people's phones within seconds — no review, no waiting. Here's my exact setup, config and all, so you can copy it instead of guessing.
I'm running this on Expo/React Native with EAS Update.
The core config
Two settings do almost all the work:
json
"updates": {
"url": "u.expo.dev/[your-project-id]",
"enabled": true,
"fallbackToCacheTimeout": 10000
}
fallbackToCacheTimeout: 10000 is the setting that actually matters. On a cold start, the app waits up to 10 seconds for a new update to download — and if one's there, it launches straight into it. The default is 0, which means any update only takes effect on the next cold start after this one. With the timeout set, updates land immediately instead of one launch late.
The trade-off is honest: on a slow connection, app start can take up to those 10 seconds longer. I've decided that's worth it — instant fixes over a slightly slower worst-case launch.
Remind the 10 seconds is a ceiling, not the norm. That's just the timeout if the download stalls. On a normal connection the update usually finishes in 1–3 seconds, so in practice you rarely feel any delay at all.
One thing to know: this only fires on a genuine cold start. Reopening the app from the background doesn't trigger a new check. Realistically though, barely anyone goes weeks or months without ever fully closing the app at least once — so in practice this basically never becomes a problem.
What actually goes over OTA — and what doesn't
OTA-able: anything in JS/TS. Screens, business logic, copy, navigation, images and other assets. If you can write it in your app code, it can ship instantly.
Needs a full store build: new native modules, version upgrades of native dependencies that touch native code, any app.json field that compiles into the native project (permissions, icons, splash screen — even the fallbackToCacheTimeout setting itself), and SDK upgrades.
One nuance worth knowing: sometimes a feature looks JS-only but only works because the native side already shipped support for it in an earlier build. A config or variable change can go out via OTA — but only because the native plumbing for it was already sitting in a build people already have installed.
The actual workflow
bash
npx eas-cli@latest update --branch production --message "description" --non-interactive
Then commit and push. One easy-to-miss detail: EAS Update publishes whatever's in your working directory right now — not your last commit. Uncommitted changes go out with the update whether you meant them to or not. So commit immediately after publishing, to keep git and what's actually live in sync.
Three more levers worth knowing about
Rollback. eas update:rollback reverts to a previous update, or all the way back to the bundle embedded in the original build. Your emergency lever if an OTA update breaks something in production.
Staged rollout. Updates can ship to a percentage of users first (--rollout-percentage) instead of going straight to 100%. I currently push straight to everyone — a staged rollout is the safer move for anything you're not fully sure about.
Code signing. Updates can be cryptographically signed so only bundles you authorized can install. Not essential for most solo setups, but worth knowing it exists.
Is this actually allowed by Apple?
Yes — with real limits, not a loophole.
Apple's guidelines technically say apps shouldn't download code that changes their features or functionality. But there's an explicit carve-out: code interpreted by JavaScriptCore or Hermes is fine, as long as it doesn't add native capabilities that bypass review. That's exactly what a React Native JS bundle is. The native layer never changes — your JS only calls into native functions that were already reviewed and approved when you submitted the build. This isn't a gray area workaround; it's the sanctioned case, and it's been standard practice since 2017.
What's inside the lines: bug fixes, copy changes, layout tweaks, feature flags toggling things that were already part of what Apple reviewed. What's not: hiding a genuinely new feature behind a remote flag and quietly switching it on later. That's the same interpreted-code mechanism, but it's used to ship something Apple never saw — and apps have been flagged for exactly that.
One honest caveat: review is policy plus judgment, not just the text of the guideline. No tool — not Expo, not any other OTA provider — can promise "Apple-approved" updates, because that certification doesn't exist. What does exist is a clear, long-standing precedent for this exact use case. Stay inside it (fixes and tweaks, not disguised new features) and you're on solid ground.
The short version
Set fallbackToCacheTimeo… continue on X ↗
That's the problem OTA (over-the-air) updates solve. Push a JS change, and it's live on people's phones within seconds — no review, no waiting. Here's my exact setup, config and all, so you can copy it instead of guessing.
I'm running this on Expo/React Native with EAS Update.
The core config
Two settings do almost all the work:
json
"updates": {
"url": "u.expo.dev/[your-project-id]",
"enabled": true,
"fallbackToCacheTimeout": 10000
}
fallbackToCacheTimeout: 10000 is the setting that actually matters. On a cold start, the app waits up to 10 seconds for a new update to download — and if one's there, it launches straight into it. The default is 0, which means any update only takes effect on the next cold start after this one. With the timeout set, updates land immediately instead of one launch late.
The trade-off is honest: on a slow connection, app start can take up to those 10 seconds longer. I've decided that's worth it — instant fixes over a slightly slower worst-case launch.
Remind the 10 seconds is a ceiling, not the norm. That's just the timeout if the download stalls. On a normal connection the update usually finishes in 1–3 seconds, so in practice you rarely feel any delay at all.
One thing to know: this only fires on a genuine cold start. Reopening the app from the background doesn't trigger a new check. Realistically though, barely anyone goes weeks or months without ever fully closing the app at least once — so in practice this basically never becomes a problem.
What actually goes over OTA — and what doesn't
OTA-able: anything in JS/TS. Screens, business logic, copy, navigation, images and other assets. If you can write it in your app code, it can ship instantly.
Needs a full store build: new native modules, version upgrades of native dependencies that touch native code, any app.json field that compiles into the native project (permissions, icons, splash screen — even the fallbackToCacheTimeout setting itself), and SDK upgrades.
One nuance worth knowing: sometimes a feature looks JS-only but only works because the native side already shipped support for it in an earlier build. A config or variable change can go out via OTA — but only because the native plumbing for it was already sitting in a build people already have installed.
The actual workflow
bash
npx eas-cli@latest update --branch production --message "description" --non-interactive
Then commit and push. One easy-to-miss detail: EAS Update publishes whatever's in your working directory right now — not your last commit. Uncommitted changes go out with the update whether you meant them to or not. So commit immediately after publishing, to keep git and what's actually live in sync.
Three more levers worth knowing about
Rollback. eas update:rollback reverts to a previous update, or all the way back to the bundle embedded in the original build. Your emergency lever if an OTA update breaks something in production.
Staged rollout. Updates can ship to a percentage of users first (--rollout-percentage) instead of going straight to 100%. I currently push straight to everyone — a staged rollout is the safer move for anything you're not fully sure about.
Code signing. Updates can be cryptographically signed so only bundles you authorized can install. Not essential for most solo setups, but worth knowing it exists.
Is this actually allowed by Apple?
Yes — with real limits, not a loophole.
Apple's guidelines technically say apps shouldn't download code that changes their features or functionality. But there's an explicit carve-out: code interpreted by JavaScriptCore or Hermes is fine, as long as it doesn't add native capabilities that bypass review. That's exactly what a React Native JS bundle is. The native layer never changes — your JS only calls into native functions that were already reviewed and approved when you submitted the build. This isn't a gray area workaround; it's the sanctioned case, and it's been standard practice since 2017.
What's inside the lines: bug fixes, copy changes, layout tweaks, feature flags toggling things that were already part of what Apple reviewed. What's not: hiding a genuinely new feature behind a remote flag and quietly switching it on later. That's the same interpreted-code mechanism, but it's used to ship something Apple never saw — and apps have been flagged for exactly that.
One honest caveat: review is policy plus judgment, not just the text of the guideline. No tool — not Expo, not any other OTA provider — can promise "Apple-approved" updates, because that certification doesn't exist. What does exist is a clear, long-standing precedent for this exact use case. Stay inside it (fixes and tweaks, not disguised new features) and you're on solid ground.
The short version
Set fallbackToCacheTimeo… continue on X ↗
♥ 204 · ⟲ 14 · 👁 154.6KView on X ↗
