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.

    ServerEvery user

    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

    A web rollback reaches everyone at once. A mobile fix reaches an installed base one device at a time.

    The pipeline

    The release surface is deliberately small:

    What each trigger runs, and where the result lands
    TriggerProfileUpdate channelShips to
    Push to stagingstagingstagingInternal test track + TestFlight
    Merge to mainproductionproductionStore production as draft + App Store Connect
    Manual dispatchdevelopment—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:

    Push
    1. 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.

    2. 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
    The rule this replaced — ship a store build if anything native changed — lived in a document and was enforced by nothing. A native change is precisely what changes the hash, so the automated path can no longer ship native code over the air.

    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 regenerate android/ and ios/.

    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:

    The three version numbers, their owners, and what moves them
    NumberExampleOwned byMoves when
    Marketing version1.7.1HumanYou decide to release
    Build number / version code47Build serviceEvery build
    Runtime versionFingerprint hashFingerprint toolNative 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

    Each number answers a different question. Treating them as one is where mobile versioning goes wrong.

    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.
    Which changes can ship over the air, and which need a build
    ChangeOver the air?
    JS/JSX logic, screens, hooks, state slicesYes
    Styles, strings, translationsYes
    Bundled images and generated asset componentsYes
    Public environment values inlined at publish timeYes
    Native dependency changesNo — rebuild
    SDK upgradeNo — rebuild
    Permissions, entitlements, platform manifestsNo — rebuild
    App icon, splash screen, URL scheme, bundle IDNo — rebuild
    Config-plugin changesNo — rebuild
    Native module source patchesNo — rebuild
    Platform service-configuration filesNo — 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

      Cents
      FingerprintOTA update
    • Native change

      Dollars
      FingerprintNative buildStore submission
    Builds carry a flat fee, so the only optimization that pays is not building when no build is required.

    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.

    1. Human rule

      Written down, correct, enforced by nothing.

    2. Computed fingerprint

      A hash of the native configuration, taken on every push.

    3. OTA or native build

      The pipeline takes the path the hash implies.

    The rule did not get easier to remember. It stopped being something anyone has to remember.