10 min read
Shipping a React Native app without guessing
The release pipeline that decides for you: a fingerprint of the native configuration chooses between an over-the-air update and a store build, so the most dangerous release rule no longer depends on anyone remembering it.
A mobile release is not a deploy
On the web, you control the server. Roll it back and the next request sees the rollback.
On mobile, the product is a binary already sitting on a device you do not control. There is no equivalent instant rollback. You roll forward and wait.
That makes over-the-air updates attractive. If a bug lives entirely in JavaScript, you can ship a new bundle without waiting for a new store build to reach every user.
It also creates the dangerous question: is this change JavaScript, or does it depend on native code?
That boundary is easy to understand and surprisingly hard to remember under release pressure. A JavaScript change can go over the air. A native dependency, permission, config-plugin change, or other native change needs a new binary.
Our pipeline was built around a simple idea:
A release rule that a human has to remember is a rule that will eventually be broken.
Instead of asking a developer to remember the boundary, we make the pipeline compute it.
Web
You control the server.
Roll it back and the next request sees the rollback.
Mobile
Users already hold binaries you do not control.
The installed base is a distribution of versions, not a single version.
- 1.7.1
- 1.6.0
- 1.6.0
- 1.4.2
- 1.2.8
Illustrative versions
The pipeline
The release surface is deliberately small:
| Trigger | Profile | Update channel | Ships to |
|---|---|---|---|
| Push to staging | staging | staging | Internal test track + TestFlight |
| Merge to main | production | production | Store production as draft + App Store Connect |
| Manual dispatch | development | — | Dev-client artifact |
Two branches map to two update channels, with a manual escape hatch for development builds.
The merge to main is the release. There is no release tag or second release gate. That is a deliberate trade-off: even a JavaScript-only change becomes a production OTA update when it reaches main.
There is also an important asymmetry. Store submissions keep a human in the loop because they land as drafts or require review. An OTA update does not. Once published, it can reach compatible devices without another person approving the update.
There is no automated pull-request gate in this setup either. Lint, tests, and environment checks are run locally before pushing. It is a real gap, and the first guard we would add.
Let the fingerprint decide
The old rule was simple:
If a change touches anything native, ship a store build.
It was correct. It was documented. And nothing enforced it.
The replacement is a fingerprint of the native configuration:
Hash the native configuration
Native dependencies, config plugins, permissions and entitlements, icons, URL schemes, identifiers — everything that would force a regeneration of the native projects.
Look for a build with that hash
One question decides the whole release: has any existing build been produced from this exact configuration?
Hash matches
Publish an over-the-air update
Nothing native moved, so the change is JavaScript and assets. It reaches compatible devices without a store release.
- JS-only change
- ~2 min
- No build fee
New hash
Run a native build
Native code changed, so an over-the-air update has nowhere safe to land. It goes through a real build and a store submission.
- Native change
- ~20 min
- Then submit
A matching fingerprint means the native configuration is compatible with an existing binary, so the change can take the OTA path. A new fingerprint means the native configuration changed, so the pipeline takes the native-build path.
The important part is not the time saved. It is that the decision no longer depends on someone remembering a rule.
The fingerprint covers the native configuration: native dependencies, config plugins, permissions and entitlements, icons and splash screens, URL schemes and identifiers, and other inputs that affect generated native projects.
The useful mental model is:
The fingerprint is a hash of everything that would force you to regenerateandroid/andios/.
There are also two failure modes worth knowing because they are silent.
First, version fields must be excluded from the fingerprint. If a build-number value falls back to something that changes every CI run, the fingerprint changes every time and never matches an existing build. The pipeline quietly degrades to always building.
Second, the app configuration must not branch on the build profile in a way that changes fingerprint inputs. Different configuration values produce different fingerprints, again turning every push into a build.
We verify the mechanism by generating the fingerprint twice and confirming that the hash is identical.
There are also less obvious inputs. The build configuration in eas.json is part of the fingerprint, and so is whether android/ and ios/ are ignored by git. Changing either can force a rebuild even when application code has not changed.
That is correct behavior. It is also exactly the kind of behavior that is easy to misunderstand if the decision is hidden inside a document instead of encoded in the pipeline.
Three version numbers, three jobs
Mobile versioning becomes much easier once three different numbers stop being treated as one:
| Number | Example | Owned by | Moves when |
|---|---|---|---|
| Marketing version | 1.7.1 | Human | You decide to release |
| Build number / version code | 47 | Build service | Every build |
| Runtime version | Fingerprint hash | Fingerprint tool | Native configuration changes |
Auto-increment moves the platform build number and version code. It does not automatically bump the user-facing marketing version.
The runtime version has a different job. It is the native-compatibility key that determines which installed binaries are eligible for an update.
This is why tying runtime version directly to the marketing version creates a subtle failure.
Imagine the binary in the field has runtime version 1.7.1. You make a JavaScript-only change and bump the marketing version to 1.7.2. Because the native configuration did not change, the fingerprint stays the same.
If runtime version equals marketing version, the OTA update targets 1.7.2 — but the devices are still running 1.7.1.
The publish succeeds.
The update reaches nobody.
That is the kind of failure that is dangerous precisely because the pipeline reports success.
Marketing version
Store identity and communication
Moved by a human
Build number
Orders binaries within a store
Moved by the build service
Runtime version
Decides which binaries an update may reach
Moved by the fingerprint
The failures that do not announce themselves
Three failure modes illustrate why the decision belongs in the pipeline.
The update that reaches nobody
Publishing an update under a runtime version that matches no installed build can succeed. You get green output and an update entry, but zero devices receive it.
The automated fingerprint path prevents this mismatch from being created by the normal decision flow. A manual emergency publish can still bypass that protection, so after a manual publish we verify that the runtime version matches builds actually in the field.
The gate that went inert
A force-update mechanism depends on the installed version changing. If the marketing version is forgotten for several releases, a minimum-version check can end up comparing a constant against a constant.
Nothing necessarily errors. The gate simply stops protecting users.
The setting that locks everyone out
The dangerous configuration is raising the minimum supported version before a corresponding store build is live and downloadable.
If the remote minimum points to a version nobody can install, the installed base can be blocked with nothing available to update to. The fix then depends on a store release and its review process.
So the operational rule is simple:
Never raise the minimum supported version until a build carrying that version is live and downloadable in both stores.
These failures share one property:
The dangerous failures are the ones that report success.
That is why important release checks belong in the pipeline wherever possible, rather than only in a runbook.
What can ship over the air?
The practical rule is:
If the change requires regenerating the native projects, it needs a build.
| Change | Over the air? |
|---|---|
| JS/JSX logic, screens, hooks, state slices | Yes |
| Styles, strings, translations | Yes |
| Bundled images and generated asset components | Yes |
| Public environment values inlined at publish time | Yes |
| Native dependency changes | No — rebuild |
| SDK upgrade | No — rebuild |
| Permissions, entitlements, platform manifests | No — rebuild |
| App icon, splash screen, URL scheme, bundle ID | No — rebuild |
| Config-plugin changes | No — rebuild |
| Native module source patches | No — rebuild |
| Platform service-configuration files | No — rebuild |
Before the fingerprint existed, this kind of table was a safety mechanism. You had to classify the change correctly.
Now it is mostly a thinking aid. The pipeline makes the final decision.
What the optimization actually buys
The economics make the design easier to justify.
Builds are charged as a flat fee per build, while other jobs are charged by compute time. That means a slow build does not become more expensive simply because it takes longer, but an unnecessary build still incurs the full build cost.
So the meaningful optimization is not shaving a few minutes from a build.
It is avoiding the build when no build is required.
A JavaScript-only push follows the fingerprint and OTA path. A native change adds the build and submission steps. The cost difference is why avoiding unnecessary native builds matters more than optimizing small amounts of pipeline compute.
OTA billing also changes the release behavior: updates are tied to monthly active users rather than charged per update. That removes a financial reason to batch unrelated JavaScript fixes.
The remaining constraint to watch is bandwidth. If the JavaScript bundle changes on every publish, users may need to download it again. Bundle size therefore becomes a useful number to measure when deciding how frequently to ship OTA updates.
JavaScript-only change
CentsFingerprintOTA updateNative change
DollarsFingerprintNative buildStore submission
What we deliberately did not fix
Two things in this pipeline are worse than they could be, and both are deliberate.
Dependency installs are not cached
The hosted dependency cache does not cover the package-manager version this project uses, so dependency installation runs cold.
The obvious solution is to migrate to a supported package-manager version.
But migration changes the lockfile, and with it how dependencies resolve. That changes the fingerprint and forces a full native build round on both platforms.
At our push volume, that migration costs roughly three months of what the cache would save.
The workaround is worse: rewrite the lockfile on the build runner. That changes what gets installed during the build, and risks a mismatch between the fingerprint calculated before the build and the fingerprint recorded against the resulting binary.
If that mismatch becomes persistent, the pipeline can silently degrade to always-build.
The workaround saves cents. The failure mode can cost dollars on every push.
So we are staying put and paying for cold installs.
Not because the current setup is ideal. Not because one package manager is philosophically better. The numbers do not justify the migration yet.
Revisit when the numbers change.
There is no automated pull-request gate
Nothing currently runs lint, tests, or environment checks on a pull request.
We run them locally, which is a real mitigation. It is also the first guard we would add.
That gap is worth documenting because it is part of the actual system, not something to hide behind the polished parts of the architecture.
What we learned
- Let the pipeline decide OTA vs build. Do not rely on a rule in a document.
- Keep version fields out of the fingerprint. Otherwise the hash can change on every run.
- Do not make fingerprint inputs depend on the build profile.
- Verify the fingerprint is deterministic.
- Keep runtime version separate from marketing version.
- Treat the marketing version as an explicit release decision.
- Never raise a minimum supported version before the corresponding store build is live.
- After a manual OTA publish, verify that the runtime version matches builds in the field.
The goal was never automation for its own sake.
It was to reduce the number of ways a release can go wrong without anyone noticing.
The fingerprint turns one dangerous decision into a computed one. The versioning model makes compatibility explicit. The failure checks turn silent mistakes into visible ones.
The result is not a perfect pipeline. It still has trade-offs and deliberate gaps.
But the most important release rule is no longer something we expect a human to remember.
The pipeline remembers it for us.
Human rule
Written down, correct, enforced by nothing.
Computed fingerprint
A hash of the native configuration, taken on every push.
OTA or native build
The pipeline takes the path the hash implies.