Most React Native teams we join can build an app. Fewer can answer a harder question: if we find a bad bug at 4pm on a Friday, how fast can we fix it for users, and how confident are we that the fix will not make things worse?
That is a release engineering question, and in 2026 the answer for most Expo-based teams is a combination of over-the-air (OTA) updates and an automated build pipeline. Here is how we set that up on client projects, including the parts that bite people.
What OTA updates can and cannot change
An OTA update ships a new JavaScript bundle and new assets to an already-installed binary. That covers a surprising amount: business logic, screens, styling, copy, API endpoints, feature-flag defaults, most bug fixes.
It does not cover anything that lives in the native layer:
- adding or upgrading a library with native code (a new camera SDK, a new push provider)
- changing native permissions, entitlements, app icons, splash screens, or
Info.plist/AndroidManifest.xmlvalues - upgrading the React Native or Expo SDK version
- changing build-time configuration such as Hermes settings or new architecture flags
The rule of thumb: if npx expo prebuild would produce different native projects, you need a new binary. OTA is for JavaScript, not for native.
Runtime versions are the safety mechanism
The single most common OTA outage we get called about is an update delivered to a binary that cannot run it — new JS calling a native module that is not in the installed app. The result is a crash on launch for every user who received the update, and the users cannot get out of it because the bad bundle is cached.
The runtime version exists to prevent exactly this. Every build declares a runtime version; every update is published against one; the client only downloads updates whose runtime version matches. Configure it in app.json:
{
"expo": {
"runtimeVersion": { "policy": "fingerprint" },
"updates": {
"url": "https://u.expo.dev/<your-project-id>",
"fallbackToCacheTimeout": 0
}
}
}
The fingerprint policy hashes your native project inputs — dependencies with native code, config plugins, app config — and derives the runtime version from that hash. Change something native and the fingerprint changes automatically, so the update simply will not be offered to older binaries. That is far safer than hand-managed version strings, which people forget to bump on exactly the release where it mattered.
You can inspect the fingerprint locally before you publish:
npx expo-updates fingerprint:generate
eas update --branch production --message "Fix checkout total rounding"
If the fingerprint of your working tree does not match the fingerprint of the build in the store, that update needs a binary, not an OTA.
Channels, branches, and environments
The mental model that keeps teams sane:
- A branch is a line of updates (a sequence of JS bundles).
- A channel is what a build points at. Builds are immutable; channels are the indirection layer.
So your production build listens to the production channel, and you map that channel to whichever branch you want it to serve. Promoting a release becomes a mapping change rather than a rebuild, which is also how you roll back.
A typical eas.json:
{
"build": {
"development": { "developmentClient": true, "distribution": "internal", "channel": "development" },
"preview": { "distribution": "internal", "channel": "preview" },
"production": { "channel": "production", "autoIncrement": true }
},
"submit": { "production": {} }
}
QA installs the preview build once and then receives every merge to your staging branch over the air. That alone removes most of the "can you make me a new build?" traffic from a team's week.
Staged rollouts and rollback
Do not send a JS update to 100% of users the moment CI is green. Roll it out:
# publish to a branch nobody is pointed at yet
eas update --branch release-2026-03 --message "March release"
# point 10% of the production channel at it
eas channel:edit production --branch release-2026-03 --rollout 10
Watch crash-free sessions and your error tracker for a few hours, then raise the percentage. If something is wrong, you have two levers:
- Re-point the channel at the previous known-good branch — the fastest, cleanest rollback.
eas update:rollbackto republish the prior update on the same branch.
Either way, remember that clients apply updates on the next launch by default. Rollback is not instant for a user sitting in the app; it is instant for the next cold start. Design for that: if the bug is severe, pair the rollback with a server-side kill switch for the feature.
Load-on-launch versus load-in-background
The default behaviour — check for an update at launch, download it, apply it on the following launch — is the right default. Aggressively fetching and reloading mid-session produces the worst bug class in this whole area: a user's app state disappearing under them halfway through a form.
If you do want faster adoption, use the expo-updates API deliberately and only at safe moments:
import * as Updates from "expo-updates";
async function checkForUpdateAtSafePoint() {
if (__DEV__) return;
const result = await Updates.checkForUpdateAsync();
if (!result.isAvailable) return;
await Updates.fetchUpdateAsync();
// Prompt, do not surprise. Reload when the user is idle and has no unsaved work.
promptUserToRestart(() => Updates.reloadAsync());
}
Store policy, briefly
Both Apple and Google allow JavaScript updates to interpreted code that do not change the app's core purpose or add undisclosed functionality. Shipping bug fixes, copy changes, and iterations on reviewed features is normal and fine. Using OTA to sneak in a feature the reviewer rejected, or to change what the app fundamentally does, is not — and it is the kind of thing that costs a developer account rather than a release. Keep the app's stated purpose stable and you will never think about this again.
The pipeline
Tying it together, a workflow that works for teams from two to twenty developers:
# .github/workflows/release.yml (abbreviated)
name: release
on:
push:
branches: [main]
jobs:
ship:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- uses: expo/expo-github-action@v8
with: { eas-version: latest, token: ${{ secrets.EXPO_TOKEN }} }
- run: npm ci
- run: npx tsc --noEmit && npm test -- --ci
- run: eas update --branch staging --message "${{ github.event.head_commit.message }}"
Around that core, the pieces we always add:
- Typecheck, lint, and unit tests gate every update. An OTA update bypasses store review, so CI is your review.
- A smoke test on a real build. A short Maestro flow — launch, log in, reach the main screen — run against the preview build catches the update that boots to a white screen.
- Fingerprint diffing in CI. Compare the current fingerprint against the last production build; if it changed, fail the update job with a clear message: "native change detected, cut a new build."
- Source maps uploaded per update. Otherwise your crash reports from OTA-updated clients are unreadable stack traces.
eas build --auto-submiton tags so store submissions are a git tag rather than someone's laptop.
A short checklist
- Runtime version policy set to
fingerprint, not a hand-managed string. - Channels mapped to branches; builds never hard-code a branch.
- Every production update rolled out in stages with a documented rollback command.
- CI gates updates with the same checks you would run before a store release.
- Source maps and release tagging wired into your error tracker.
- A written answer to "who can publish to production, and how do we undo it?"
Get those six right and Friday afternoon stops being scary. Most of the value here is not the tooling — it is the discipline the tooling makes cheap.
If your release process is still a person running commands locally and hoping, get in touch. Setting up a pipeline like this is usually a short, high-leverage engagement, and it is one of the first things our consultants look at when we join an existing team.