Section 06

Over-the-air updates

Ship a JavaScript fix to everyone already running your app, without a build, without a submission, and without waiting on review. This is the feature people ask for first, because it turns a two-day emergency into a two-minute one.

What you can and cannot send this way

An update replaces your JavaScript and assets — screens, logic, copy, images, fonts. It cannot change native code. Adding a library with a native module, upgrading the Expo SDK, changing permissions or app icons: all of those need a new build submitted to the stores. Sending JS that calls native code the installed binary does not have will crash the app on launch for every user who receives it.

runtimeVersion is the whole safety mechanism

Every binary you build has a runtimeVersion baked into it, and every update is published against one. A device only accepts an update whose runtime version matches its own. That single string is what stops the crash described above.

Set it in app.json:

"expo": {
  "runtimeVersion": "1.4.0",              // or a policy:
  "runtimeVersion": { "policy": "appVersion" },
  "updates": { "url": "https://updates.yourcompany.com/api/manifest" }
}

"appVersion" is the sensible default: your runtime version follows expo.version, so bumping the app version for a native change automatically stops old updates reaching new binaries. Bump it whenever native code changes, and never otherwise.

The number one confusion

"I published an update and nothing happened" is almost always a runtime version mismatch — the installed app was built against a different one, so the server correctly refuses to serve it. leas update:list shows the runtime version of every update; compare it against the build that is actually on the device.

Channels

A channel is a named stream of updates. Devices ask for one by name, so the same app binary can follow preview internally and production in the store.

leas update --channel preview --message "Try the new checkout"
# looks good in testing — same bundle, no re-export
leas update:promote --from preview --to production

Promotion is additive: the update stays available on preview and becomes available on production too. Nothing is rebuilt, so what you tested is byte-for-byte what ships.

Publishing

leas update --channel production --message "Fix checkout crash"

This runs expo export, hashes every asset, and files the result under an id derived from the export's own metadata — so republishing identical code produces an identical id, exactly as EAS does. The channel then points at it, and the next device to check in gets it.

Rolling back

Faster than publishing, because nothing is rebuilt — a rollback just re-points the channel.

leas update:rollback --channel production                    # back one update
leas update:rollback --channel production --to <updateId>     # back to a specific one
leas update:rollback --channel production --embedded          # back to the binary itself

--embedded is the emergency stop. It tells devices to discard every downloaded update and run the bundle compiled into the app they installed from the store. Reach for it when you are not sure which update broke things — it returns everyone to a known-good state in one command, and nothing is deleted, so you can point the channel back afterwards.

Running the update server

Unlike the dashboard, this one has to be reachable by phones on the internet.

leas update:serve --url https://updates.yourcompany.com

It is a plain Node HTTP server with two endpoints — /api/manifest answers "is there anything new for me?", and /api/assets serves the bundle and images. Host it anywhere that runs Node. For local testing against a real device, expose your machine with a tunnel and pass the tunnel's URL to --url; asset URLs are generated from it, so a wrong value produces manifests whose assets nobody can fetch.

Code signing

Without it, anyone who can reach your update URL — or spoof it — can run arbitrary JavaScript inside your app. With it, the app refuses any update not signed by your key.

leas update:keys
npx @expo/code-signing-certificates generate-certificate \
  --key-input-directory credentials/updates \
  --certificate-output-directory credentials/updates

Then point expo.updates.codeSigningCertificate at the generated certificate and rebuild. From then on leas signs every manifest and directive it serves, and a device that cannot verify the signature ignores the update rather than running it.

Turn this on before you have users

Enabling code signing requires a new binary, because the certificate is compiled into the app. Doing it on day one costs nothing; doing it after launch means waiting for everyone to update through the store first.

What a device actually does

  1. On launch, the app asks your manifest endpoint, sending its platform, runtime version, channel, and the id of the update it is currently running.
  2. leas replies with a manifest for a newer update, or a no update available directive, or a roll back to embedded directive.
  3. Given a manifest, the app downloads each asset and verifies its hash before use.
  4. The new version launches next time the app starts — or immediately, if you call Updates.reloadAsync() yourself.

That handshake is Expo's published updates protocol, so the stock expo-updates library talks to leas with no patches — you point expo.updates.url at your server and nothing else changes.