Ship React Native fixes in minutes, not days
A customer reports a crash. You find it in ten minutes. The fix is three lines. You merge it.
Now what? On the web, it's live before you've closed the tab. On mobile, you join a queue you don't control.
Over-the-air (OTA) updates close that gap. This post covers how they work, why fingerprinting is the idea that makes or breaks them, and the CI/CD setup we use to publish them safely.
The queue you don't control
Apple reviews 90% of submissions in under 24 hours. Google usually takes one to three days. That sounds fine, until you add up the rest of the trip from merge to user:
- Review. A day if you're lucky.
- Rejection. A missing privacy string, a reviewer who couldn't log in. Every rejection sends you to the back of the queue.
- Rollout. Apple can phase an update over seven days. Google rolls out by percentage. You should use both, and both add days.
- The user. Who has auto-update off, or hasn't opened the app.
So a three-line crash fix reaches your users somewhere between three days and never.
OTA updates skip all of it, for the changes that qualify. Merge to main, and the fix is on phones in minutes. The whole game is knowing what "qualify" means.
What an OTA update replaces
A React Native app is two things: a native binary (the Swift/Kotlin shell, native modules, permissions) and a JavaScript bundle (your components, your logic, your images).
The store reviews and ships the binary. The JS bundle just happens to be inside it.
An OTA update swaps the bundle and leaves the binary alone. On launch, the app asks an update server whether a newer bundle exists, downloads it, and runs it next launch. The native shell — the part Apple and Google actually reviewed — never changes.
That gives us a simple rule:
JavaScript and assets can ship over the air. Anything native cannot.
Simple to say, hard to judge. A new screen is obviously JS. Adding expo-location is obviously native. But what about a dependency bump that pulls in a native module three levels down? A new config plugin? An SDK upgrade?
You can't answer that from memory, and you definitely can't answer it at 11pm during an incident. So don't. Let the machine answer.
Fingerprinting
Every installed build carries a runtime version. Every update you publish carries one too. The server sends an update to a device only when the two match.
Expo can compute that version for you. Set the policy to fingerprint:
{
"expo": {
"runtimeVersion": { "policy": "fingerprint" },
"updates": { "url": "https://u.expo.dev/<your-project-id>" }
}
}
Now the runtime version is a hash of everything that affects the native build: your dependencies, android/ and ios/, config plugins, app.json, eas.json. Your application code is deliberately left out. Components and business logic don't move the hash — which is exactly what makes it useful:
- You edit a component. The hash is unchanged. The update matches the live build, and the fix ships.
- You add a native module. The hash changes. The update now targets a runtime version that no installed build has.
That second case is the trap, and it fails quietly. eas update exits zero. CI goes green. The update sits on the server, perfectly valid, and no device ever asks for it.
Nobody tells you. You find out when the customer emails again next week.
Failing loudly
So the important part of our pipeline isn't the publish step. It's the check before it:
#!/usr/bin/env bash
set -euo pipefail
# The build users actually have installed.
BUILD_ID=$(eas build:list --channel production --status finished \
--limit 1 --json --non-interactive | jq -r '.[0].id')
# Compare its fingerprint against the current project.
eas fingerprint:compare --build-id "$BUILD_ID" --json --non-interactive > fp.json
BUILD_FP=$(jq -r '.fingerprint1.hash' fp.json)
PROJECT_FP=$(jq -r '.fingerprint2.hash' fp.json)
if [ "$BUILD_FP" != "$PROJECT_FP" ]; then
echo "::error::Fingerprint changed — this can't ship over the air. It needs a store release."
exit 1
fi
In one sentence: find the build that's live in the stores, hash the current project, and stop if they disagree.
We run it in two places.
On every pull request, as a required check:
name: Mobile OTA Check
on:
pull_request:
paths: ['mobile/**']
jobs:
ota-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm install -g eas-cli
- run: ./scripts/check-ota-deliverable.sh
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
This isn't really a gate. A red X doesn't mean the PR is bad — plenty of good PRs add native code. It means this won't reach users without a store release, said at review time instead of discovered a week later. The reviewer sees it too.
And again on merge to main, right before publishing:
name: Mobile OTA Update
on:
push:
branches: [main]
paths: ['mobile/**']
jobs:
ota-update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm install -g eas-cli
- run: ./scripts/check-ota-deliverable.sh
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
- run: eas update --channel production --message "$(git log -1 --pretty=%s)"
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
Why check twice? Because main can move between the PR passing and the merge landing.
When it can't be an OTA
The check tells you when you need a store release. You still have to make that easy, or people will start ignoring the check.
We drive store releases off a tag. Release Please reads conventional commits, opens a PR that bumps the version and writes the changelog, and cuts a tagged release when it merges. That tag triggers a build and submit to both stores.
One wrinkle bit us here, and the obvious fix is the wrong one.
The version lives in app.json. app.json is native config. Native config is in the fingerprint. So a version bump drifts the fingerprint by itself, and the OTA check fails on the release PR every time — on a change that contains no code.
The tempting fix is to teach the check to look away:
if: ${{ !startsWith(github.head_ref, 'release-please--') }}
That works. But read what it says: this check is wrong, so here's a branch name where we agree to ignore it. That's how a check starts dying. The next false positive gets its own exception, and the one after that gets continue-on-error.
The better fix is to stop the version bump from drifting the fingerprint at all:
// fingerprint.config.js
module.exports = {
sourceSkips: ['ExpoConfigVersions'],
};
This drops version, versionCode and buildNumber from the hash. That's not a hack — it's correct. None of them change how the native shell behaves, so none of them should change which bundles that shell can run. The fingerprint models native compatibility, and a version number isn't native compatibility.
The lesson generalises: when a good check keeps firing on a case you know is fine, it's usually telling you its inputs are wrong. Fix the inputs.
Worth saying that the store lane automates too — nobody should be clicking through a console on release day. But the platforms stop in different places, and that catches people out.
On Android, EAS Submit (--auto-submit) uploads straight to the production track, and on Google Play landing on production is the review submission. It's done. You need a service account key, and Google makes you upload manually once before API submissions work at all.
On iOS, EAS Submit gets the build to App Store Connect and into TestFlight — then stops. It does not submit for review. That last hop needs Fastlane's deliver, with submit_for_review to put the build in front of Apple and automatic_release so an approval at 3am becomes a release at 3am. Authenticate with an App Store Connect API key, not an Apple ID — two-factor auth is what usually kills these pipelines.
So the release lane ends up: tag → EAS builds both platforms → Android goes to Google by itself → Fastlane submits the iOS build to Apple. No one logs into a console.
Undoing a bad update
Publishing to production on every merge should make you a little nervous. It's only reasonable because you can take it back.
Ship to a slice of users first:
eas update --channel production --rollout-percentage 10
Widen it with eas update:edit once it's been quiet, or pull it back with eas update:revert-update-rollout if it hasn't. If a bundle is genuinely bad, recovery is publishing the last good one again — the same three minutes the bad one took. And clients can always fall back to the update embedded in the binary they installed, so whatever the stores reviewed still runs.
That's the real argument for OTA. Not that shipping is faster, but that unshipping is. A mistake you can undo in three minutes is a different kind of mistake from one that takes days.
What OTA is not
It's not a way around review. Apple allows updating interpreted code, but not to change the app's purpose or add features that would have failed review. Google says the same. Slipping past a rejection is how you lose the account.
It's not free of compatibility risk. Once JS and binary are decoupled, your users fragment: some on the latest bundle, some on a two-month-old shell, all talking to the same API. Version your API and treat the mobile client as an external consumer.
What it comes down to
Every part of this pipeline exists to answer one question: does this change touch native code. A fingerprint answers it every time, without anyone needing to remember to ask.
If the answer is no, the fix ships on merge and the store never hears about it. If the answer is yes, the pipeline says so on the pull request, in red, while the author is still looking at it.
None of this makes shipping reckless — it makes the reckless version impossible. A native change can't slip out over the air, because the pipeline stops it before it can. The speed everyone notices is the side effect; the point is that merging to main stops being a thing you think twice about.