An over-the-air update replaces the JavaScript bundle and assets of an installed app without a store release. The trainee app uses expo-updates with EAS Update.

Concepts

An installed app only receives an update when both the channel and the runtime version match. A binary built as version 1.0.5 only loads updates published with runtime 1.0.5. The app does not call the expo-updates API from its own code. It relies on the default behaviour: check for an update on launch, download it in the background, and apply it on the next cold start. A trainee therefore needs two launches to see a new update.

Publishing

To the current version

update:preview and update:production are aliases for the same script. scripts/ota-push.sh does three things:
  1. Checks that the profile is production or preview. Without a message it uses OTA <profile> <UTC date and time>.
  2. Runs node scripts/sync-eas-ota-env.js <profile>. This reads build.<profile>.env from eas.json and pushes APP_ENV and every EXPO_PUBLIC_* key to the matching EAS environment with eas env:set. Other keys are skipped as native-build-only. This step exists because an update bundle is built on your machine with EAS environment variables, not with the build profile’s env block. Without the sync an update could ship with a different API URL than the binary it lands on.
  3. Runs eas update --branch <profile> --environment <profile> --message "<message>".
The runtime the update targets is whatever expo.version is in app.json at that moment.

To every version still in use

Trainees stay on old store versions for a long time. A fix published only to the newest runtime reaches only people who already updated from the store.
scripts/ota-all-runtimes.sh publishes the current bundle once per runtime:
  1. Refuses to run when app.json has uncommitted changes.
  2. Takes the runtime list from the arguments, or uses the default list written in the script.
  3. For each runtime, rewrites expo.version in app.json to that value and runs pnpm ota:production "<message> (runtime X)".
  4. Restores app.json with git checkout in an exit trap, so an interrupted run does not leave the wrong version behind. A wrong version left in app.json would mis-target the next update and the next store build.
The default runtime list in the script is 1.0.6 down to 1.0.0. It does not include the current expo.version. After a multi-runtime publish, also run pnpm ota:production so the newest binary gets the update. When you bump the app version, add the previous version to the list in the script.

Verify every publish

After a multi-runtime publish, check that each update group was recorded under the runtime its label says:
Compare the runtime version shown for each group with the (runtime X) suffix in its message. If a runtime is missing, publish it again on its own:
A duplicate group built from the same commit is harmless.

The native module trap

An OTA bundle may only use native modules that exist in every binary it is published to.
Publishing one bundle to every runtime deliberately defeats the protection the appVersion policy gives. The runtime version says “this JavaScript matches this native code”. When you send today’s JavaScript to a binary built two months ago, that promise is only true if the JavaScript does not need anything the old binary lacks.

What goes wrong

  1. A dependency with native code is added, for example a new Expo module, and imported at the top of a file that runs at startup.
  2. A new store build includes it. Old installs do not.
  3. An update is published to all runtimes.
  4. On an old binary, loading the module throws while the bundle is being evaluated. The app never renders. expo-updates treats the launch as failed and falls back to the embedded bundle.
  5. Every later update fails the same way. Those installs are frozen on the JavaScript their store build shipped with.
Nothing crashes visibly. From the outside it looks like one platform has a bug that the code does not have, because one platform’s store build is older than the feature. This has happened in this project: one platform ran a month-old bundle while the other updated normally, and the symptom reported was a broken feature, not a broken update.

A try/catch around a dynamic import is not enough

It is natural to write this and assume it is safe:
It is safe during startup and unsafe afterwards. When a module’s factory throws and no other module is being loaded at that moment, Metro’s runtime reports a fatal error instead of rethrowing. An import triggered by a tap or an effect therefore never reaches the catch, and a release build closes. This is how photo uploads crashed the app on binaries built before the image manipulation module was added.

The safe pattern

Probe for the native module first. Import only when it is there.
That is the loader from src/lib/media/shrinkImage.ts. shrinkImage returns null when the loader does, and the caller uploads the original image. The reference implementations are src/lib/media/shrinkImage.ts and src/features/movement/lib/cardioNotification.ts. The second one probes CardioNotification, the local Android module, so an update on a binary built before the module simply shows no notification. requireOptionalNativeModule finds Expo modules. It cannot see a Nitro module such as the HealthKit library. Guard those with a platform and version check instead, and test on an old binary.

Rules

  • Never import a native module at module scope in a file that runs at startup.
  • Use the name the native side registers (for example ExpoImageManipulator), not the npm package name.
  • The feature must degrade when the probe returns nothing: hide the button, skip the step, or use a JavaScript fallback.
  • Adding a native module means store builds for both platforms.
  • Until every runtime in the OTA list has the module, the guard must stay.
  • When installs have moved off an old version, remove it from the runtime list in scripts/ota-all-runtimes.sh.

Diagnosing a platform-only bug

Before you debug the feature, check whether the device is running the code you think it is.
  1. Compare store build dates with the date the feature was added: eas build:list --platform android and the same for ios. A store build older than the feature, combined with a working update channel, means the update may be failing to launch.
  2. Run eas update:list --branch production and look for a rollback entry on the reporter’s runtime that no later publish has replaced.
  3. Remember the two-launch rule. A device that has just downloaded the fix still shows the old behaviour until it is relaunched.

Rollback

A rollback tells devices on the current runtime to run the bundle embedded in their store build. Know two things before you use it:
  • It applies per runtime. Rolling back one runtime does not touch the others.
  • It pins that runtime to its embedded bundle until a newer update is published for the same runtime. The embedded bundle can be weeks old. A rollback is a way to stop a crash, not a way to return to yesterday’s update.
To return to a specific earlier state, check out that commit and publish it again as a new update. Publishing forward is almost always better than rolling back to embedded.

What an OTA update can and cannot change

See Mobile builds for when a store build is required.

Publish checklist

1

Confirm the change is JavaScript only

Check the diff of package.json, app.json, plugins/, modules/ and patches/. If any of them changed in a way that affects native code, this is a store build, not an update.
2

Confirm every native import is guarded

Search the diff for new imports of packages with native code.
3

Confirm the API is ready

The update will call the production API. Every endpoint and field it needs must already be deployed.
4

Run the checks

5

Make sure app.json is clean and on the right version

The multi-runtime script refuses to run otherwise.
6

Publish to preview first when the change is risky

Open a preview build twice and test.
7

Publish to production

Publish to the current version, then to the older runtimes that still have installs.
8

Verify

Check eas update:list --branch production. Open a production build on each platform, relaunch it twice, and confirm the change is there.