Famitha M AHow we run OTA updates in production without breaking sessions: three update tiers, a rollback runbook, and EAS channels as a rollout dimension.
OTA updates are the closest thing React Native has to a cheat code. You can ship a fix to every user in minutes without waiting on App Store review. And that is exactly why most teams get burned by them: the same speed that ships fixes fast also ships bugs fast, to everyone, at once.
This is the playbook we settled on after enough OTA incidents to stop treating eas update like a deploy button.
The failure pattern is almost always the same: a team discovers OTA, loves it, and starts using it as their default release path. No staged rollout, no rollback plan, updates applying whenever the client feels like it. It works fine for weeks. Then one bad bundle lands on 100% of users simultaneously, and there is no "roll back the servers" because the broken code is already on the devices.
The fix is not to avoid OTA. It is to give it the same discipline you give backend deploys.
Not every OTA push is the same kind of event. We label every update as one of three tiers before it goes out.
Tier 1: Hotfix. A crash or a broken critical path. Goes to 100% as fast as possible. This is the only tier allowed to skip a canary, and it requires two people to sign off.
Tier 2: Canary. Behavior changes, refactors, anything with real surface area. Ships to a small channel first, soaks, then promotes.
Tier 3: Staged. Larger feature work that happens to be JS-only. Rolls out gradually with explicit checkpoints.
Naming the tier forces the conversation: "is this really a hotfix, or are you just impatient?"
The rule that has saved us the most: no update ships without a rollback line already written. Concretely, the PR description for an OTA push includes:
Rollback plan:
- Known-good update group: <id of the current production update>
- Command: eas update:republish --group <id> --channel production
- Owner if this goes wrong after hours: <name>
Republishing a known-good group is the core move. You are not reverting git and rebuilding under pressure. You are re-pointing the channel at a bundle that was already live and healthy:
# find the last known-good update group
eas update:list --branch production
# re-point production at it
eas update:republish --group <update-group-id>
Two habits make this actually work:
update:list output trying to remember which one was fine.One caveat to internalize: rollback is not instant for users. Devices pick up the republished bundle on their next check, so your real recovery time is rollback plus the client's update-check cadence. That number should be written down somewhere your team can see it.
The default temptation is to call Updates.reloadAsync() the moment a download finishes. From the user's side, that is the app blinking and eating their half-written message.
The pattern we use: fetch eagerly, apply lazily, at safe points only.
import * as Updates from 'expo-updates';
import { useEffect, useRef } from 'react';
import { AppState } from 'react-native';
export function useSafeOtaApply() {
const pendingUpdate = useRef(false);
useEffect(() => {
async function check() {
try {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
pendingUpdate.current = true; // downloaded, NOT applied
}
} catch {
// never let update plumbing crash the app
}
}
check();
// apply only when the app goes to background: user is gone anyway
const sub = AppState.addEventListener('change', (state) => {
if (state === 'background' && pendingUpdate.current) {
Updates.reloadAsync();
}
});
return () => sub.remove();
}, []);
}
Safe points, in order of preference:
The one thing you never do is reload while a form, checkout, or upload is in flight. If your app has flows like that, gate the reload behind a "is anything critical in progress" check.
Most teams use channels as environment labels: development, preview, production. That works, but it leaves the interesting part on the table. Channels are also how you slice your user base for rollout.
Our channel layout:
production <- everyone on the store build
production-canary <- internal team + opted-in users
staging <- QA builds
The canary build is the same runtime as production (same native code, same runtime version), just pointed at a different channel. A Tier 2 update flows like this:
# 1. ship to canary
eas update --channel production-canary --message "fix: cart total rounding"
# 2. soak. watch crash rate and the specific flow you touched.
# 3. promote the same update to production
eas channel:edit production --branch <canary-branch>
Promoting the branch rather than publishing twice means production gets the exact bytes the canary soaked on. No "it worked on canary because we rebuilt it" mysteries.
Since runtimeVersion fences which builds can receive which updates, keep canary and production builds on the same runtime version policy or your canary stops being representative.
The boundary is mechanical, not judgment-based:
We also run a soft rule: if an OTA diff is big enough that you would want a changelog, it probably deserves the store pipeline and its slower, safer cadence anyway.
This calculus changes a bit depending on how the app was built. A lot of the apps we see coming out of RapidNative are Expo projects where the entire product surface is JS, which makes almost everything OTA-eligible. That makes the discipline above more important, not less: when every change can go OTA, the tiering and rollback rules are the only thing standing between you and pushing straight to 100%.
For a Tier 2 change, end to end:
1. PR merged with rollback plan in the description
2. Note current production update group id
3. eas update --channel production-canary
4. Soak on canary (crash rate + touched flow)
5. Promote canary branch to production channel
6. Watch dashboards through one full update-check cycle
7. Log the new known-good group id
Seven steps, and five of them are copy-paste. The whole loop is still dramatically faster than a store release, which is the point: the discipline does not slow you down enough to notice, but it converts "OTA incident" from an outage into a non-event.
What does your OTA setup look like: are you applying on cold start, on background, or (be honest) reloading the second the download finishes? Drop your setup in the comments, especially if you have a rollback story.