Tech 13 min read

React Native OTA after CodePush: Stallion vs EAS Update, rollback and signing

IkesanContents

OTA (Over-the-Air) updates for React Native apps deliver JavaScript bundle changes without a store submission.
Microsoft’s CodePush, the de facto standard, shut down in March 2025 along with Visual Studio App Center, leaving teams to choose between Expo’s EAS Update, self-hosting, or standalone services like React Native Stallion.
Starting from Stallion’s feature set (Shipping React Native Updates Without the App Store), this post looks at what OTA can and cannot fix, and what rollback and signing infrastructure you actually need.

OTA updates replace the JavaScript bundle and some assets inside a React Native app without touching the native binary.
It is a convenient shortcut, but not a mechanism that lets you change anything you want while bypassing store review.
Apple’s App Review Guidelines explicitly prohibit downloaded code from significantly changing an app’s purpose.
OTA fixes are limited to JavaScript-layer changes within an already-reviewed app.

The OTA boundary is the native boundary

A React Native app splits into the native shell submitted to App Store / Google Play, and the JavaScript bundle loaded inside it.
Screen text, API calls, state management, and JS-based UI logic are OTA-eligible.
Adding a new native module, requesting new permissions (camera, location), modifying Info.plist or AndroidManifest.xml, or updating native SDK dependencies — all of these require a store submission.

Blurring this boundary turns OTA from an operational convenience into an incident waiting to happen.
Expo’s EAS Update documentation uses runtimeVersion to express compatibility between the native code layer and the JavaScript update layer.
An update only applies when platform and runtimeVersion match.
Push a new JavaScript bundle against the wrong runtimeVersion and you feed new JS to an old binary.

Post-CodePush options

CodePush was the standard React Native OTA service for years.
Bundled with App Center, free, well-documented — for a long time you could just say “use CodePush” and move on. Then Microsoft announced the retirement of Visual Studio App Center, and the service ended on March 31, 2025.
Teams that depended on CodePush as a hosted service had to migrate to Expo EAS Update, run a CodePush-compatible server themselves, or switch to a service like Stallion or Hot Updater.

EAS Update is the natural choice for Expo projects.
eas update generates a bundle and uploads it to EAS servers; channels and branches control which builds receive which updates.
In Expo’s model, a channel is embedded in native code at build time, and a branch is a sequence of updates. Reassigning which branch points to which channel lets you route different update streams to the same production-channel build.

For bare React Native projects that do not want to adopt Expo’s conventions, the remaining options are self-hosting or a managed service.
Self-hosting offers maximum flexibility but also maximum responsibility: delivery server, signing, rollback, metrics, CDN, and incident response are all on you.
OTA looks like “just putting JavaScript somewhere,” but what you actually need in production is a way to stop a broken bundle and a way to track which device is running which code.

Stallion leans toward incident handling, not just delivery

React Native Stallion is a managed OTA delivery service for React Native.
Its GitHub repository describes it as a platform covering JavaScript bundle delivery, testing, rollback, analytics, and staged rollout.
The SDK and CLI are open-source; the management console is a hosted service.

What stands out about Stallion is not “deploy without the store” but the tooling for stopping, rolling back, and throttling after deployment.
Upload a bundle to a bucket, promote it to production from the dashboard, and specify target app versions and rollout percentage.
Start at 0% for internal users, then ramp to 5%, 25%, 50%, 100% — bringing the same staged rollout discipline that store releases have to JavaScript updates.

Delta delivery matters too.
Traditional OTA sends the entire JavaScript bundle even for a one-line fix.
Stallion’s OTA update guide and patch update documentation emphasize file-level diffs and delta delivery to reduce update size.
The actual savings depend on change scope and bundle structure, so measure against your own app — text fix, API layer change, image swap — rather than trusting marketing numbers.

Signing is non-negotiable.
An OTA delivery server is a place that hands executable JavaScript to apps after the fact.
Accepting unsigned bundles means a compromised delivery path or admin console translates directly into arbitrary code running on user devices.
Stallion advertises bundle signing, but the question to ask during evaluation is not just “is there signing?” but who holds the key, how it is handled in CI, and how revocation and rotation work.

Release versioning matters more than integration code

Integrating Stallion is straightforward.
Install the SDK, point getJSBundleFile (Android) or bundleURL (iOS) to Stallion, use Metro for development builds and OTA for release builds.
This part is the same as any OTA tool.

The harder part is deciding how to slice releases.
When store version, runtimeVersion, OTA release, Git commit, and API compatibility drift apart, it becomes impossible to answer “which device is running which code” during an incident.
EAS Update has channels and branches; Stallion has buckets and releases; self-hosting needs a custom release table. All of them need a mapping between native build number and JavaScript update.

Define what “rollback” means up front.
Pausing new downloads and rolling back already-updated devices to a previous bundle are two different operations.
A bundle that crashes on launch needs automatic rollback.
A bundle that breaks one specific API might be faster to mitigate with a server-side feature flag.
Once you adopt OTA, app-side release management and server-side feature control merge into the same incident response flow.

How far can game apps push “update without a store release”?

React Native OTA works by swapping the JavaScript bundle — code is delivered, so UI changes and logic fixes ship without the store.
The flip side: anything that requires a new native module is out of reach.

Unity-based game apps approach this problem from the opposite direction.
As discussed in the Mirishita backend post, THE IDOLM@STER Million Live (Mirishita) delivers all songs, costumes, and 3D models via AssetBundles from a CDN, controls event schedules and gacha rates through server-side master data, and gates new features behind feature flags.
The result: new songs, new events, and even mid-sized feature additions ship without a store update.
So how far can this approach actually stretch?

Code itself cannot be delivered

Unity’s AssetBundles can externalize almost everything Unity serializes: models, textures, audio, animations, ScriptableObjects, Timelines.
Anything visible on screen and anything audible is covered.

C# scripts cannot be included in AssetBundles.
IL2CPP builds compile C# to native binaries, and iOS prohibits JIT compilation, which rules out dynamic code loading.
Where React Native OTA is “code can be delivered, native modules cannot,” Unity games face the inverse: “assets can be delivered, code cannot.”

Feature flags are not omnipotent either.
Turning a feature on via server flag requires the code for that feature to already exist in the app binary.
When Mirishita made new modes like Idol Grand Prix or AR Photo suddenly available, the code had been shipped in a prior store update — the flag just enabled it.
It is a two-stage approach: embed the code first, activate it with data later.

Lua scripting bridges the gap, but App Store rules are gray

C# code cannot be delivered, but embedding a scripting engine in the game enables logic changes via data delivery.
Chinese game studios commonly use Lua bindings (xLua, ToLua) — Lua script files are text data, so they can be swapped via AssetBundles or server downloads.
Battle logic, AI behavior, and event scenario branching can all live on the Lua side, enabling changes without a store update.

Genshin Impact and Arknights use this approach partly because Android distribution in China involves dozens of stores beyond Google Play, making store-by-store update delivery impractical.
The structural incentive to minimize store updates is strong.

However, Apple’s App Store Review Guidelines Section 2.5.2 states that downloaded code must not change app functionality (with exceptions for WebKit-based engines like JavaScriptCore).
Whether Lua-driven logic changes violate this clause is a gray area — many titles pass review, but rejections have been reported.
Compared to React Native OTA, which is explicitly permitted as JavaScriptCore execution, the Lua approach has weaker regulatory grounding.

Content updates vs. feature updates

In practice, what game apps manage to ship without the store roughly breaks down as follows. Content additions (characters, costumes, songs, maps, scenario text) go entirely through AssetBundles, fully outside the store.
Numeric balance adjustments (parameters, drop rates, event rewards) live entirely in server-side master data.
In Mirishita’s case, monthly song additions, weekly events, gacha refreshes, and costume additions all fall within these boundaries.

New game rules or new UI screens involve code changes, so they either need to be pre-embedded in the binary or require a store update.
The “major update” that mobile games announce with a mandatory store download usually hits this boundary.

Where you draw this line depends heavily on game design philosophy.
A fully data-driven design can express even battle logic as combinations of parameters and rule tables in master data.
A card game that defines all rules in server-side rule tables can add new card effects without code changes.
Conversely, hard-coding logic on the client side means even small rule tweaks require a store update.

React Native OTA has the same structural property: design your behavior around API responses and you may not even need OTA; bake business logic into the client and OTA frequency goes up.
The architecture decision of how much judgment to give the client comes before the choice of store-bypass tooling.

TestFlight makes store bypass a non-issue

Everything above assumed an app published on the App Store or Google Play.
For personal projects used only by the developer, or distributed to a small group, store review is irrelevant.

On iOS, TestFlight works.
With an Apple Developer Program membership, upload a build through App Store Connect and install it on your own device via internal testing.
Internal testing does not go through Apple review, so the build is installable the moment it is uploaded.
Up to 100 people can be added, and the 90-day TestFlight beta expiration resets when a new build is uploaded.
On Android, use Google Play Console’s internal testing track, or just sideload the APK.

Under these conditions, OTA’s biggest selling point — “ship without waiting for store review” — disappears.
The effort of Archive → Upload from Xcode for a TestFlight build and uploading a bundle for OTA are comparable.
OTA’s remaining advantages are instant reflection without app reinstall and smaller transfer sizes from delta delivery, but for a personal app, reinstalling is not a big deal either.

Running OTA requires delivery server, signing verification, and rollback infrastructure regardless of the method.
Weigh that maintenance cost against re-uploading a TestFlight build, and TestFlight wins in most personal-use scenarios.
OTA becomes worthwhile for personal development only in specific cases: pushing the same fix to multiple test devices instantly, frequently changing config values for A/B testing, or updating multiple times per week.

PWA Service Worker cache updates share the same difficulty as OTA

Kana Chat runs as a PWA.
PWAs are web apps — no App Store, no Google Play, deploy to the server and it reaches users.
OTA as a concept seems unnecessary, but once a Service Worker starts caching, things get complicated.

A Service Worker caches HTML, CSS, JavaScript, and images in the browser for offline use and performance.
With a Cache First strategy (serve from cache without hitting the network if available), deploying to the server does not make the browser fetch new files.
From the user’s perspective, “I deployed but nothing changed.”

Earlier in this post, OTA’s concern was “you need a way to roll back a broken bundle.”
With PWA Service Workers, the problem runs in the opposite direction.
OTA fears “delivering too much”; PWA Service Workers fear “not reaching the user.”

The safe approach: change the Service Worker file’s hash on every deploy, call skipWaiting() to activate the waiting Service Worker immediately, and clients.claim() to switch existing tabs to the new worker.
With Workbox, precacheAndRoute manages per-file revision hashes and swaps only changed files into the new cache.
What this amounts to is OTA-style delta delivery: detect changes, deliver diffs, switch immediately.

skipWaiting has a caveat though.
If the new Service Worker activates while a page loaded under the old worker is still open, the old HTML may reference new JS or CSS.
For full-page consistency, showing the user a “New version available. Reload?” prompt and requiring an explicit reload is safer.
This mirrors the OTA pattern of “check for updates on launch → apply on next launch.”

Tearing down a server-side worker also requires a deploy

This blog previously ran a server-side analytics backend on Cloudflare Workers.
The setup sent browser events to a Worker endpoint, which wrote them to Supabase — collecting server-side data separately from GA4’s client-side tracking.

When it was no longer needed, removing the client-side tracking script did not stop the Worker.
Cloudflare Workers keep responding as long as requests match their route.
Even after the client stops sending events, the Worker process remains deployed at the edge.
Fully stopping it requires wrangler delete, deleting through the Cloudflare dashboard, or deploying an empty Worker to overwrite it.

Where PWA Service Workers have a “cache won’t let go” problem, edge workers have a “code keeps running when it shouldn’t” problem.
The directions are opposite, but the property is the same: deployed code does not disappear on its own, and tearing it down requires an active deployment.

OTA bundles delivered to devices share the same structure.
Stopping new bundle downloads from the delivery server does not remove the old bundle from devices that already downloaded it.
Overwriting the device’s bundle requires delivering a rollback bundle.
React Native OTA, PWA Service Workers, CDN edge workers — all of them operate on the same lifecycle: “retracting deployed executable code requires deploying different code.”