Continuous Deployment for React Native When You Can't Break on Game Day
Continuously shipping a mobile app is not the same as shipping a website. This is our system of keeping bad deployments from becoming crises, and why the native layer makes it challenging.
TL;DR
- Shipping a mobile app continuously is a different problem than the web. The store gate, the native-versus-JavaScript split, and the fact that an over-the-air bundle can't outrun the native runtime it shipped with mean you can't just merge and deploy.
- We're building a pipeline that splits every change by what it actually needs. JavaScript ships to production the same day it merges; native changes ride their own release train, and the Expo fingerprint decides which path a change takes automatically.
- The payoff is that a bad deploy stops being a crisis. Small same-day batches are cheap to revert, native freezes stop holding everyone else's work hostage, and most of this is still being built, so the post is as much about the open problems as the wins.
When we upgraded to Expo SDK 54, we spent the whole release week chasing problems with the new native build. We kept finding issues and delaying the release by over a week. While that build sat in validation, everything else queued up. Code that had missed the original cut was merged to main, held in a holding pattern, unreleased for upwards of 17 days, because one native change had frozen the normal release.
We were not completely stuck. We have a hotfix path: cherry-pick a JavaScript-only change onto the release branch and push it OTA (“Over-The-Air”). But it is manual. It runs through a separate environment and requires manual QA, creating constant context switching and repeated debates over whether each fix is safe to ship. A copy fix, a config flag, a one-line bug fix, the kind of changes that should be trivial, become a small fire drill. That is what a weekly, hand-gated release does to you. It chains every change to the slowest one in the batch, so a routine SDK upgrade turns into an org-wide release freeze leaving only one option: run by hand. Rolling a release back means clawing back a week of other people's shipped work, so in practice we almost never do; we fix forward, one cherry-pick at a time. Our traffic peaks on game nights, and that is the worst possible time to be hand-carrying fixes through a side channel.
What we are building collapses that distinction. The goal is to retire the separate hotfix process entirely, because the everyday path will do what the hotfix path does today, automatically, and with validation built in. JS (“JavaScript”) will ship to production the same day it merges, in small batches, with no store review and no forced update. Native changes ride their own train. The SDK upgrade will still take as long as it takes, but it will stop holding everyone’s work hostage while it does. And because the JS batches are small, a bad OTA deploy becomes an annoyance you revert in minutes rather than an outage. Native regressions are harder. I’ll come back to why.
A note before the architecture. Most of this is still being built. One gate is live today. The rest are in active development. I’ll mark which is which as we go.
Why mobile breaks the web's rules
On the web, nothing technical stands between merging a pull request and putting it in production. You might gate it behind review or a release process, but the platform is not the thing stopping you. Mobile is different; it puts up three walls:
- The store gate. Native code means a new binary and an Apple or Google review whose timing you do not control. It might clear in hours or sit for days, and a rejection puts you back in the queue, so you can't promise a deploy window when someone else holds the clock.
- The native and JS split. Pure JS and their assets can ship OTA in minutes, with no review and no update prompt. Native changes can't. So the entire game is sorting one from the other before anything merges.
- The snapshot rule. An OTA bundle is a full snapshot with the native runtime baked in. You can't send one bundle to two runtimes, and you can't skip commits. If commit N is native and N+1 needs it, N+1 crashes any build still on the old runtime. Every native change is a fork point. That’s not theoretical: reference a native module that isn't in the installed binary, and the app crashes on first render for everyone who takes the update.
The challenge lies in identifying which side of the line a change occupies before it reaches the codebase. This is where the fingerprint becomes essential. By hashing every file that impacts the native runtime, Expo’s fingerprinting allows CI to determine if a change is ota-eligible or requires a fresh binary. Under this model, any PR triggering a native delta is sequestered until the next release cycle, while everything else flows directly to production. It ships the same day, bypassing store reviews and update prompts entirely. That is how we eliminate the guesswork and ensure a single native regression no longer holds everyone’s work hostage.

How we rewrote the rule book
My first design used a single long-lived branch that always represented production. It worked better on paper than in practice. One of our engineers wrote a counter-proposal that took it apart: force-updating a static release branch loses all git history between releases and can only target the latest build, not the users still running older ones. He was right, and we never shipped my version. The model we settled on makes the branch the release: release-branch trains. Because the branch is the release, release/12.63 can keep taking fixes long after it shipped, so a critical fix can reach users on 12.62 while 12.64 sits in store review. We took the best of both proposals, and that is the model we are building.
The pipeline is designed around two modes, driven entirely by the fingerprint.
- Tracking. Zero pending native changes. The live release branch stays synced with main, shipping OTA bundles on a set schedule. This steady state is our default baseline, where the pipeline spends the bulk of its time.
- Diverged. A native change forks the train. The current branch freezes, a new branch carries the native build into store review, and OTA-eligible fixes cherry-pick onto the frozen branch so users on the current build keep getting fixes. When the new build goes live, it becomes the next tracking branch.

A second native change, while the first build is still in review, forces a call: fold it into the pending build, or start a second train. The controller that makes that call is still on the drawing board, but the design puts the tradeoff right on the PR, in plain language:
This PR changes the native fingerprint while native/12.64 is in App Store review.
Absorb: add it to 12.64 and resubmit. Resets the review clock, another 24 to 48 hours.
Queue: hold it for 12.65 and run a parallel review. 12.64 still ships on schedule.
Reply /absorb or /queue.
Every change runs the gauntlet
We are building toward three deploy windows a day, morning, midday, and evening, with a /deploy-now escape hatch; a window with nothing new to ship is a no-op. Before anything reaches a window, a change clears four gates.

Only the first gate runs today. Every PR has to clear the same required checks before it can merge to main: unit, type, lint, dependency-cycle, and a Maestro end-to-end smoke suite (Maestro is the open-source mobile UI testing framework) that exercises the core flows. The rest is the design we are building toward. The deploy gate will run the full Maestro suite against the actual OTA bundle, so a regression in a path the smoke subset does not cover fails before it reaches members. Post-deploy, every OTA will be tagged in Sentry for observability and watched across our device and OS matrix, because no CI run covers the real mobile landscape. There are thousands of Android device models alone, plus a long tail of OS versions, OEM skins, and screen sizes that no test lab can fully reproduce. On the web you target a handful of browser engines, and on the backend you own the runtime; on mobile you ship into a hardware population you can only sample, so the failures hiding in its corners only surface once real devices on real networks run your code.
Rollback is still ahead of us, and the trigger is the part we want to get right. An absolute crash-free threshold is the obvious idea and the wrong one. On a busy night, a traffic surge or a backend hiccup can dent crash-free rates for reasons that have nothing to do with the deployment, and auto-reverting the bundle in the middle of peak load is exactly the wrong move. Because every OTA will be tagged in Sentry, the plan is to do something better: compare the new update against the build it replaced, scoped to the users who took it, and roll back only when the new bundle is measurably worse than its known-good predecessor. If the backend is down, both cohorts suffer equally, the difference stays flat, and a deployment that was not the problem does not get reverted.
Since a bundle can break a flow without crashing: a jump in caught JS errors per session, or a drop in a critical customer journey such as submitting an entry, will be able to trigger a similar response. The exact margins, and the number of sessions we need before we trust the comparison, are values we will calibrate from our own release-over-release history rather than constants we guess up front. Native is harder because you can't un-ship a binary. There, we patch forward with OTA JS updates and rely on expedited store review for the rare crash a JS patch can't reach.
What we're still debating
This is a rollout, not a victory lap. Here are the open questions, and the interim call we live with on each.
- Rolling an OTA update back more than one step, to a specific known-good version by tag. Today, this process is fully manual and must be reverted by hand. Once the automatic rollback exists, the first version will only revert to the last good update, and if that one is also bad, a human still picks the known-good tag.
- The exact trigger for that automatic rollback: how much worse than its predecessor a new bundle has to be, and how long we watch, before the pipeline acts on its own. Until the automation is in place, a person watches Sentry after every deployment and makes the call.
- Real EAS bandwidth numbers, which should set the deploy cadence. At our scale this is a real cost, not a rounding error: every over-the-air update ships to a large, highly engaged user base, so an update on every merge adds up fast. An app with a few thousand users can deploy continuously without thinking about bandwidth; we can't, so until we have hard numbers the plan caps cost with three fixed windows a day.
- Keeping the full end-to-end suite fast enough to gate a deployment without making the windows meaningless. The plan is to parallelize it across devices to keep it inside a couple of minutes, but as coverage grows, holding that line gets harder.
- Version skew, in both directions. JS can quietly depend on a native module the installed binary does not have yet, and it can get ahead of the backend it talks to. Earlier this year we shipped a display change that was not backward-compatible with an older backend still in production and did not degrade gracefully, and members saw the wrong label on their entries for almost two days. Shipping JS the same day it merges makes both kinds of skew more likely, not less. The fingerprint is meant to catch the obvious native cases, and the design has CI validate each cherry-pick against the live release branch so a bad one fails before it merges. For now we hold the line in code review and the smoke suite; real contract checks against the services the bundle calls are on the list, not yet built.
Steal this
You probably do not have an NBA playoff schedule, but you have something like it: a payments app at month-end, a retailer on Black Friday, a window where breakage costs more than usual. Split your deploy paths by what actually needs a build, automate that call, and treat every native change as a fork point you plan around. The schedule will not wait for your release train. Ours definitely does not.
We build this in the open
Look back at how much of this post was open questions. The rollback controller that decides when to revert, the state machine that picks absorb or queue when a second native change lands, the contract checks that keep a same-day JS deploy from getting ahead of the backend: that is the work, and most of it is still being built. A seat just opened up on the mobile platform team to build it with us. If release-train state machines and OTA fork points sound like a good time, we should talk. We are hiring a Staff React Native engineer. Come join us.
Joel Kanzelmeyer manages the mobile platform team at PrizePicks.