The situation
You are the lead mobile engineer on a React Native app that ships to 150 000 active iOS and Android users. The app currently uses the legacy **EAS Build Classic* workflow (the expo build:* commands) and OTA updates are delivered via expo publish. A security patch is required, but the team also wants to adopt the newer EAS Build v2 and EAS Update pipelines because they promise faster builds, deterministic caching and the ability to roll back OTA releases with a runtimeVersion bump.*
When you try to run eas build --profile production with the new config, the CI job fails on the iOS side with the error Failed to resolve the following native dependencies: expo-modules-core. On Android the build succeeds, but the OTA update that follows is rejected on devices with the message EXPO_UPDATES_ERROR: Runtime version mismatch. The immediate cost is a stalled security patch, a day of lost developer time, and a risk that the next scheduled release will be delayed.
The goal is to move from the old approach to the new one incrementally, keeping the app live and avoiding a dedicated freeze window.
Why it happens
Different native dependency resolution – Classic builds use the Expo Go client’s bundled native modules. EAS Build v2 resolves each module from the
node_modulestree, so any mismatched version ofexpo-modules-core(or other unimodules) triggers the “Failed to resolve” error.Runtime version enforcement –
expo-updatesnow validates that theruntimeVersionin the OTA manifest matches the one baked into the native binary. The old workflow never set aruntimeVersion, so devices that still run the classic binary reject updates produced by the new pipeline.Cache incompatibility – The new build system caches compiled artefacts per
eas.jsonprofile. If the profile is missing acacheconfiguration, the first build will re‑install all pods, inflating build time and increasing the chance of transient network failures.Gradual rollout expectations – OTA updates are served to the entire user base by default. When a subset of users still runs the classic binary, a blanket rollout will cause a split‑brain state where half the devices accept the update and half reject it, leading to a spike in crash reports.
CI pipeline assumptions – Many CI scripts assume a single
eas.jsonfile. Introducing a second profile without guarding theeas buildcommand can cause the wrong profile to be used, producing mismatched bundles that are not OTA‑compatible.
These failure modes are documented in the EAS Build and Updates docs, but the docs do not spell out the incremental migration path that keeps the app live while both binaries coexist.
The fix, step by step
Below is a tested migration sequence that works on a CI runner using EAS CLI 3.10.2, Expo SDK 49, iOS 15+, and Android 13. Adjust versions accordingly.
1. Freeze the current production binary in the OTA manifest
eas update --branch production --message "Freeze classic binary" \
--runtime-version 1.0.0-classic
Why: By pinning the current classic build to a distinct runtimeVersion, you guarantee that any OTA built with the new pipeline will be rejected until you deliberately bump the version.
Verify: In the Expo dashboard, open the Updates tab for the production branch. The latest entry should show runtimeVersion: 1.0.0-classic.
2. Add a new build profile for the new pipeline
Edit eas.json:
{
"cli": {
"version": ">=3.10.0"
},
"build": {
"production-new": {
"distribution": "internal",
"android": {
"gradleCommand": ":app:assembleRelease"
},
"ios": {
"workflow": "managed",
"buildConfiguration": "Release"
},
"cache": {
"clear": false,
"paths": ["node_modules/**", "ios/Pods/**"]
},
"runtimeVersion": {
"policy": "appVersion"
}
}
}
}
Why: A dedicated profile isolates the new build from the existing production profile, preventing accidental overwrites. The runtimeVersion policy appVersion will automatically generate a version string like 1.2.3 based on the version field in app.json.
Verify: Run eas build:list and confirm the new profile appears:
$ eas build:list
┌─────────────┬───────────────┬─────────────┐
│ Build ID │ Platform │ Profile │
├─────────────┼───────────────┼─────────────┤
│ 1234567890 │ ios │ production │
│ 0987654321 │ ios │ production-new │
└─────────────┴───────────────┴─────────────┘
3. Align native dependencies
On a fresh checkout, run:
npm ci
expo doctor
If expo doctor reports any mismatched unimodule versions, pin them in package.json. For the error seen in step 1, add:
"expo-modules-core": "~0.13.0"
Then reinstall:
npm install
Why: Ensuring the exact versions that the new build expects eliminates the “Failed to resolve native dependencies” error.
Verify: expo doctor should now output All systems nominal.
4. Generate a new OTA manifest that matches the new binary
Create a new branch for the migration OTA:
eas update --branch migration --message "Prepare OTA for new binary" \
--runtime-version 1.0.0-new
Why: This manifest will be served only to devices that have installed the new binary, keeping the classic users on the frozen runtime.
Verify: In the dashboard, the migration branch entry should show runtimeVersion: 1.0.0-new.
5. Build the new binary internally
eas build --profile production-new --platform ios
Why: An internal build lets you test the new binary without exposing it to all users.
Confirm success: The CLI prints a URL like https://expo.dev/artifacts/.... Download the .ipa and install on a test device via TestFlight or eas device:create.
6. Smoke‑test OTA on the new binary
On the test device that now runs the new binary:
eas update --branch migration --json
The device should log:
✅ New update available: runtimeVersion 1.0.0-new
If you see EXPO_UPDATES_ERROR: Runtime version mismatch, double‑check the runtimeVersion in app.json and the OTA manifest.
7. Gradual rollout to a percentage of users
Add a rollout configuration to the migration branch in the dashboard:
- Rollout: 10 % of users
-
Target: only devices that have reported
runtimeVersion=1.0.0-new
Why: This ensures that only users who have installed the new binary can receive the OTA, while the majority remain on the classic binary.
Verify: After 24 h, the dashboard should show 10 % rollout with a success rate > 99 %. Crash logs should stay flat.
8. Promote the new binary to production
Once the 10 % rollout is stable:
eas build --profile production-new --platform ios --non-interactive
Upload the resulting build to the App Store (or Play Console) as a phased release. In the release notes, mention “Supports OTA updates via the new EAS Update pipeline”.
When the store marks the new version as available to all:
eas update --branch production --runtime-version 1.0.0-new
Why: This swaps the runtimeVersion used by the production OTA branch, making the new binary the default target for future OTA releases.
Verify: Devices that have updated to the new version should now receive OTA updates without the runtimeVersion mismatch error. Classic devices will continue to receive OTA from the production branch until they are manually upgraded via the store.
9. Decommission the classic profile
After at least one full release cycle (≈ 2 weeks) with no active classic users:
eas update --branch production --runtime-version 1.0.0-new
# optional: delete the old profile from eas.json
Why: Keeping the old profile adds maintenance overhead and can cause accidental misuse.
Confirm: eas update:list should only show the production branch with the new runtime version.
What it costs, and when not to do it
| Cost item | Approximate impact | When it becomes a blocker |
|---|---|---|
| Additional CI time – each new profile triggers a clean pod install and npm install. On our CI (GitHub Actions, macOS‑latest) this adds ~ 6 min per iOS build. | Small but accumulates if you run nightly builds. | If your CI budget is already saturated, the extra minutes may push you over the free tier. |
Dual OTA branches – you now maintain production (classic) and migration (new). Each branch consumes a small amount of storage in the Expo dashboard (≈ 5 MB per manifest). |
Negligible storage cost, but operational overhead (remembering which branch to target). | If you have > 10 simultaneous OTA branches, the dashboard UI becomes cluttered and mistakes are more likely. |
| User‑experience risk – devices that have not upgraded via the store will stay on the classic binary and will not receive any OTA updates until they install the new version. | Users on older OS versions may be unable to upgrade, leading to a permanent split‑brain fleet. | If a critical hotfix must be delivered to all devices within hours, the split‑brain state is unacceptable. |
Bundle size increase – keeping both classic and new native modules in node_modules temporarily inflates the bundle by ~ 2 MB. |
May affect download time on slow networks. | If your app already pushes the 150 MB limit for OTA bundles, the extra size could cause OTA failures. |
Complexity in versioning – you must manage two runtimeVersion schemes (*-classic and *-new). |
Requires disciplined release notes and CI scripts. | If the team does not have a dedicated release manager, version drift can happen. |
When not to adopt
- If you have a critical security hotfix that must be delivered to every device within a day, the safest path is to stay on the classic pipeline until the hotfix is shipped, then schedule the migration during a planned freeze.
- If your app supports legacy OS versions that cannot install the new store binary, you will be forced to keep the classic binary indefinitely, making the migration moot.
- If your CI budget cannot accommodate the extra build minutes and you cannot switch to a self‑hosted runner, the incremental approach may delay other feature work.
At your level
| Role | Take‑away | One concrete next step |
|---|---|---|
| Student / junior engineer | The migration hinges on runtimeVersion alignment; a mismatched version silently blocks OTA. | Run eas update --branch test --runtime-version 1.0.0-test on a local device and verify the log shows “New update available”. |
| Working engineer (feature owner) | Use a separate eas.json profile and a dedicated OTA branch to keep classic users alive while you test the new binary. |
Add a production-new profile to eas.json and push a nightly internal build. |
| Senior / staff engineer | The incremental path trades a small increase in CI time for zero downtime; the key is a phased store release combined with a staged OTA rollout. | Implement a phased release in the App Store and configure a 10 % rollout in the Expo dashboard before expanding. |
| Lead / director | Budget impact is limited to a few extra CI minutes per week; the risk is a split‑brain fleet that can be mitigated with clear rollout metrics. | Allocate a one‑day sprint to audit runtimeVersion usage across all services and add monitoring for EXPO_UPDATES_ERROR spikes. |
In the interview
Typical interview prompt
“We have a React Native app that still uses the classic
expo buildpipeline, but we want to move to EAS Build v2 and EAS Update without a freeze window. How would you plan and execute that migration?”
Weak answer
- Lists generic steps like “update the CLI, run
eas build, and publish”. - Fails to mention
runtimeVersion, OTA branch segregation, or phased store releases. - Provides no numbers, no failure modes, and no discussion of trade‑offs.
Why it’s weak: It shows knowledge of the commands but not of the production constraints that make the migration risky.
Strong answer
-
Identify the constraint – OTA updates are rejected when
runtimeVersionmismatches. - **
Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.
Top comments (0)