Two apps can look identical. Same screens, same features, same stack. And yet one converts at 12% and the other at 4%. From the outside, nobody can explain why. From the inside, the answer is embarrassingly simple: one team ships 10x more iterations.
This is the story of how we built a self-hosted Over-The-Air update system for our React Native app — and why it matters far beyond the engineering.
The compound problem
At Inshallah, we have over 400,000 daily active users. At that scale, every micro-interaction matters. The color of a button, the timing of a loading spinner, the copy on an empty state — each of these can move completion rates by 1 or 2%. Individually, that sounds like noise. Compounded across dozens of touchpoints, it's the difference between an app that works and an app that wins.
But here's the thing about compounding: it only works if you iterate fast enough. And mobile has a built-in speed limit — App Store review.
A typical App Store submission takes 24 to 48 hours for review. Sometimes longer. Multiply that by the back-and-forth of a rejected build, add the time it takes for users to actually update, and a single change can take a week to reach your entire user base. That's a week per experiment. At best, you're running two or three experiments a month.
We needed to run two or three a week.
What OTA actually changes
Over-The-Air updates let you push JavaScript bundle changes directly to users' devices — no App Store, no Google Play, no review wait. The user opens the app, the new code is already there (or downloads silently in the background and applies on next launch).
There are limitations. OTA only covers JavaScript-side changes — if you add a new native module, a new permission, or change anything in the native binary, you still need a store release. But in practice, the vast majority of the iterations that move metrics are JS-side: UI tweaks, copy changes, flow adjustments, A/B test variants, bug fixes. That's exactly the territory where speed matters most.
Expo, the framework we use, offers their own OTA solution called EAS Updates. It works well — but it's priced per update request. At 400K DAU, those requests add up fast. We were looking at significant monthly costs that would only grow as we grow. More importantly, we wanted full control over the rollout pipeline, the rollback logic, and the infrastructure.
So we built our own.
The architecture: simpler than you think
The core idea behind any OTA system is straightforward:
1. You build a JavaScript bundle (the compiled version of your app code).
2. You upload it to a storage backend.
3. When the app launches, it asks a server: "Is there a newer bundle for me?"
4. If yes, it downloads it and swaps the old one.
That's it. The complexity is in the details — versioning, signing, rollback, compatibility — but the principle is dead simple.
We went with hot-updater, an open-source library purpose-built for this, combined with Cloudflare R2 for storage and D1 for metadata. The entire infrastructure runs on Cloudflare's edge network, meaning the update check happens at the closest data center to the user, anywhere in the world. Latency is negligible.
Cloudflare R2 stores the bundles. It's S3-compatible, with a generous free tier. At our scale, storage costs are essentially zero.
Cloudflare D1 stores the metadata — which bundles exist, which are active, which runtime version they target. Serverless SQLite at the edge.
A Cloudflare Worker serves the check-update endpoint. When the app asks "is there something new?", the Worker queries D1, finds the right bundle, and redirects to R2. Runs in milliseconds.
hot-updater's CLI handles the build and deploy — it bundles your JS, uploads to R2, writes metadata to D1, and you're live.
The total infrastructure cost at 400K DAU? Under a dollar a month.
The two modes that matter
Not all updates are equal. A copy tweak on a settings screen and a fix for a payment crash don't deserve the same deployment strategy.
We use two modes:
Silent updates (95% of deploys). The bundle downloads in the background while the user is using the app. On their next cold start, the new version is there. No interruption, no prompt, no friction. The user never knows it happened. This is the default for every normal iteration.
Forced updates (critical fixes only). The app shows a blocking splash screen with a progress bar and won't let the user in until the new bundle is applied. We reserve this for crashes, security issues, or anything that breaks core functionality. In practice, we use it maybe once every couple of months.
The forced mode is our emergency brake. The silent mode is our daily driver. Together, they give us the confidence to ship fast without risking the user experience.
The safety net: fingerprinting and rollback
The scariest thing about OTA is pushing a broken update to hundreds of thousands of people. Two mechanisms protect against this.
Fingerprinting ensures you never push a JS bundle that's incompatible with the user's native binary. The system computes a hash of the native layer — dependencies, native modules, build configuration. If you add a new native module between App Store releases, the fingerprint changes, and the OTA system knows to only serve bundles that match. Users on the old binary keep getting old-compatible bundles. Users on the new binary get new ones. No crashes from mismatched code.
Instant rollback is even simpler. Every bundle has an "enabled" flag. If something goes wrong, you flip the flag to false. Every user on that bundle immediately rolls back to the last known good version on their next launch. It's a one-click operation from a dashboard. At our scale, the problem is contained within minutes, not days.
We also sign every bundle cryptographically. The private key lives in our CI secrets, the public key is embedded in the native binary at build time. Nobody can push a malicious bundle to our endpoint — the app would reject it.
The CI pipeline: one button to ship
The human process is simple. A developer pushes a JS-only fix. Our GitHub Actions workflow:
1. Checks the native fingerprint — has anything in the native layer changed since the last App Store build?
2. If yes: blocks the OTA deploy and flags that an App Store build is needed first.
3. If no: builds the JS bundle, signs it, uploads to Cloudflare, and it's live.
There's a manual trigger for forced updates. Everything else is automatic. The whole deploy takes under two minutes.
This is the part that actually creates the compound effect. When shipping is a two-minute operation instead of a two-day operation, the team's behavior changes. People ship smaller changes. They test one hypothesis at a time. They fix bugs the same day they're found instead of batching them for the next release. The feedback loop shrinks from weeks to hours.
What this looks like in practice
Here's a concrete example. We noticed that a particular screen in our onboarding flow had a 68% completion rate. Not terrible, but we suspected we could do better. In a traditional mobile release cycle, we might have designed a new version, built it, submitted it, waited for review, waited for adoption, measured the results, and iterated — maybe two or three versions over two months.
With OTA, we pushed seven variations in two weeks. Adjusted copy, moved a CTA, changed a loading state, tweaked validation feedback. Each change was small, each was measurable, and each was live within minutes of merging. By the end of the two weeks, that screen was at 74%. Six percentage points, from changes that individually moved the needle by 1-2%.
That's the compound effect in action. And it's happening across every screen, every flow, every week.
| Metric | App Store cycle | OTA cycle |
|---|---|---|
| Time to deploy a JS change | 24–72 hours | 2 minutes |
| Time to reach full user base | 5–7 days | < 24 hours |
| Experiments per month | 2–3 | 10–15 |
| Time to rollback a bad deploy | 24–48 hours | < 1 minute |
| Infrastructure cost (400K DAU) | N/A | < $1/month |
The honest limitations
OTA is not magic. There are real constraints worth knowing.
No native changes. If you need a new camera permission, a new push notification category, or a new native module, you're back to the App Store. OTA only covers JavaScript — which is a lot, but not everything.
Bundle size matters. Each update is a full JS bundle download (typically 1-3 MB for a React Native app). On slow connections, this can take a few seconds. Silent mode handles this gracefully, but forced mode means the user waits.
You own the ops. With a managed solution, someone else handles everything. With self-hosted, you manage the storage, the worker, the deploy pipeline, the monitoring. It's not a lot of work once it's set up, but it's not zero either.
Store compliance. Both Apple and Google allow OTA updates for JavaScript-only changes — it's how solutions like Expo Updates and the late CodePush have operated for years. But the rules require that updates don't fundamentally change the app's purpose or introduce features that would require a new review. Stay within those boundaries.
Why this matters beyond engineering
The real insight isn't technical. It's strategic.
Two competing apps with the same feature set, the same design quality, the same engineering talent — but one ships 10x more iterations per month. After six months, the fast shipper has run hundreds of micro-experiments across every screen. They've optimized every conversion funnel, every error state, every piece of copy. The slow shipper has run maybe a dozen.
From the outside, both apps look "the same." From the metrics, they're in different leagues. The gap isn't talent or budget. It's cycle time.
Self-hosted OTA is one piece of that puzzle. It removes the single biggest bottleneck in mobile iteration speed — the App Store review cycle — for the vast majority of changes that actually move metrics. It costs nearly nothing to run. And it compounds every single week.
For any team building a hybrid mobile app at scale, this is one of the highest-leverage infrastructure investments you can make. Not because the technology is complex — it isn't. But because it changes how fast your team learns.
And in the long run, the team that learns fastest wins.