Cover: over-the-air updates, what 100,000 users a month cost with 4 updates of 3 MiB: EAS Update Production $449, EAS Update Starter $504, Revopush Growing $107.75, Hot Updater self-hosted on Cloudflare R2 about $5 plus your time; Microsoft CodePush retired 31 March 2025

An over-the-air (OTA) update swaps the JavaScript bundle and assets of a React Native app without a new store build. A crash fix reaches users in minutes instead of waiting for App Review or a Play rollout. Since Microsoft retired App Center on 31 March 2025, CodePush is no longer a hosted service, so teams choose between Expo's EAS Update, a CodePush-compatible service, or hosting updates themselves. Below are the store rules that decide what you may ship this way, what each option costs at 10,000, 100,000 and 500,000 monthly users, how big an update really is (measured on our own app), and how each option rolls back a bad release.

What Apple and Google allow

Both stores allow downloaded code only when it runs in an interpreter or virtual machine and doesn't change what the reviewed app is. Apple's Developer Program License Agreement, section 3.3.1(B) "Executable Code", reads: "an Application may not download or install executable code. Interpreted code may be downloaded to an Application but only so long as such code: (a) does not change the primary purpose of the Application by providing features or functionality that are inconsistent with the intended and advertised purpose of the Application (b) does not bypass signing, sandbox, or other security features of the OS; and (c) for Applications distributed on the App Store, does not create a store or storefront for other Applications."

App Review Guideline 2.5.2 says apps may not "download, install, or execute code which introduces or changes features or functionality of the app". Apple does enforce this. In March 2026 it removed the vibe-coding app Anything from the App Store and cited 2.5.2 and 3.3.1(B), as 9to5Mac reported on 30 March 2026. That app generated and ran new apps inside itself. A team pushing a bug fix to its own app is in a different position, but the same clause applies to both.

Google Play's Device and Network Abuse policy says an app "may not modify, replace, or update itself using any method other than Google Play's update mechanism" and "may not download executable code (such as dex, JAR, .so files) from a source other than Google Play". It then adds: "This restriction does not apply to code that runs in a virtual machine or an interpreter where either provides indirect access to Android APIs (such as JavaScript in a webview or browser)." A React Native bundle (JavaScript, or Hermes bytecode run by the Hermes VM) reaches Android APIs only through the native modules already in the reviewed build, which is why OTA updates fit under this exception. Neither store has published a separate rule on Hermes bytecode. EAS Update and CodePush have shipped it to store apps for years.

In practice, the line falls here:

text
change                                              OTA update        new store build
JS/TS bug fix, copy, styles, layout                 yes               -
images and fonts loaded from the JS bundle          yes               -
new screen built only from native modules           yes, if it fits   -
  already in the build                              the app's purpose
feature flag flips, A/B variants of existing UI     yes               -
new native module, or a native SDK version bump     -                 yes
React Native or Expo SDK upgrade                    -                 yes
permissions: Info.plist strings, AndroidManifest    -                 yes
app icon, splash screen, display name               -                 yes
entitlements (push, App Groups, associated domains) -                 yes
config plugin changes (app.json plugins)            -                 yes
a new purpose, or a store of other apps             no                a new review
                                                    (Apple 3.3.1(B)(a) and (c))

The native rows aren't policy choices. A bundle that calls a native module missing from the installed binary crashes. That's why every OTA system tags builds and updates with a runtime or binary version and serves an update only to builds that match it.

CodePush after App Center

Three things happened to CodePush in 2025. App Center, which hosted it, closed on 31 March 2025. Microsoft published a standalone code-push-server repo so teams could host CodePush themselves, then archived that repo, along with the react-native-code-push client, on 20 May 2025. Both are now read-only. The client's README says it won't support the New Architecture, and React Native 0.82 and later run only on the New Architecture, so the original client can't be used on a current app (our test app below is on React Native 0.88).

What replaced it:

text
option                          client SDK                              server
EAS Update                      expo-updates (works in bare RN too)     Expo, hosted
Revopush                        CodePush-compatible SDK, RN 0.76+       Revopush, hosted
                                and the New Architecture                (Enterprise: AWS, GCP or yours)
CodePushNext fork               @code-push-next/react-native-code-push  any CodePush-compatible server
                                (RN 0.76+, old and new architecture)
Hot Updater (MIT, ~1.8k stars)  its own SDK; Metro, Expo, Rock builds   you host it: Supabase, Cloudflare
                                                                        R2 + D1, AWS S3 + Lambda@Edge,
                                                                        Firebase, Vercel or Azure
Microsoft code-push-server      original client, old architecture only  archived 20 May 2025

If your app already calls codePush.sync(), switching to a CodePush-compatible service mostly means changing the server URL and deployment keys. Moving to EAS Update or Hot Updater means replacing the client library and your release scripts. Hot Updater's licence adds one condition to MIT: if you offer its server or console as a hosted service to others, you must show "Powered by hot-updater". Running it for your own apps needs no notice.

What it costs at 10,000, 100,000 and 500,000 users

All three hosted options bill by monthly active users (MAU), meaning devices that download at least one update in the month. Expo counts "a unique installation of your app on a device" once per billing period, however many updates it downloads. Users who download nothing aren't counted.

text
plan (list prices, 26 Oct 2026)  per month  MAU included      bandwidth included     over the allowance
EAS Update Free                  $0         1,000 (hard cap)  100 GiB                none, capped
EAS Update Starter               $19        3,000             100 GiB + 40 MiB per   $0.005/MAU to 200,000,
                                                              extra MAU              then tiered; $0.10/GiB
EAS Update Production            $199       50,000            1 TiB + 40 MiB per     same tiers; $0.10/GiB
                                                              extra MAU
Revopush Starter                 $0         1,000             10 GB                  $1 per 1,000 MAU;
Revopush Startup                 $25        50,000            100 GB                 $0.03/GB egress
Revopush Growing                 $100       300,000           1 TB                   (all plans)
Revopush Business                $250       500,000           2 TB
Revopush Professional            $500       1,000,000         5 TB
Cloudflare R2 (self-hosted)      $0         -                 egress free            storage $0.015/GB-month

Expo's overage tiers above the included users are $0.005 per MAU up to 200,000, $0.00375 from 200,001 to 500,000, $0.0034 to 1,000,000, $0.003 to 2,000,000, and lower bands down to $0.00085 above 100,000,000. Each band applies only to the users inside it.

A worked example: an app that ships 4 updates a month, each a full 3 MiB download (between our 0.56 MiB test app below and the 5 MiB Expo uses in its own billing examples). Every active user downloads 4 x 3 = 12 MiB a month.

text
monthly users                  10,000             100,000             500,000
bandwidth (users x 12 MiB)     117.2 GiB          1,171.9 GiB         5,859.4 GiB

EAS Starter
  base                         $19                $19                 $19
  MAU over 3,000               7,000 x $0.005     97,000 x $0.005     197,000 x $0.005 = $985
                               = $35              = $485              + 300,000 x $0.00375 = $1,125
  bandwidth over allowance     $0                 $0                  $0
  total                        $54                $504                $2,129
EAS Production
  base                         $199               $199                $199
  MAU over 50,000              -                  50,000 x $0.005     150,000 x $0.005 = $750
                                                  = $250              + 300,000 x $0.00375 = $1,125
  bandwidth over allowance     $0                 $0                  $0
  total                        $199               $449                $2,074
Revopush
  plan                         Startup $25        Growing $100        Business $250
  egress over plan             25.8 GB x $0.03    258.3 GB x $0.03    4,291.5 GB x $0.03
                               = $0.77            = $7.75             = $128.75
  total                        $25.77             $107.75             $378.75
Hot Updater on Cloudflare      $0                 $5                  $6.50
  (Workers + R2 fees only)

Three things stand out. Bandwidth costs nothing on EAS here, because each user above the included count brings 40 MiB of allowance and this app uses 12 MiB per user. Bandwidth only appears on an EAS bill once you ship more than about 30 to 40 MiB per user per month. On EAS the bill is driven by users. Production becomes cheaper than Starter at 39,000 MAU (19 + 36,000 x $0.005 = $199), and above 50,000 Starter costs exactly $55 a month more. Revopush is about a quarter of EAS Production at 100,000 users and under a fifth at 500,000 (gigabytes counted as 1,000 MB, GiB converted at 1.0737).

The self-hosted line counts Cloudflare fees only. We assume each user opens the app 30 times a month and each launch makes one update check: 300,000, 3 million and 15 million requests. Workers' free plan allows 100,000 requests a day. The $5 paid plan includes 10 million a month and charges $0.30 per extra million. R2 egress is free, storage is $0.015 per GB-month after 10 GB free, and bundle downloads stay within the 10 million free Class B reads. The fees are small. The real cost is the time spent running, monitoring and upgrading an update server that your users' app launches depend on.

How big an update is: our own test

We exported our hello-aipps test app (Expo SDK 58, React Native 0.88, 616 modules, Hermes) with npx expo export, the same step eas update runs before uploading, on 26 October 2026. Then we changed one line of text and exported it again.

text
hello-aipps update bundle              Android (.hbc)     iOS (.hbc)
raw Hermes bytecode                    1,556,456 bytes    1,551,370 bytes
gzip -9                                  714,488 bytes      711,391 bytes
brotli quality 11                        582,951 bytes      580,669 bytes

one-line text change, Android:
new bundle                             1,556,479 bytes
bytes that differ at the same offset   1,201,315 (77%)
binary delta (git pack delta)            136,435 bytes raw, 57,920 compressed
delta vs full compressed bundle         57,920 / 706,903 = 8.2%

Two results matter here. A one-line change rewrites 77% of the bytecode at the same positions, because the offsets after the edit shift. Without patches, every update is a full re-download, about 0.57 MB with Brotli even for this tiny app. A binary delta brings that down to 8.2% of the compressed bundle. We used git's pack delta as a stand-in because no bsdiff tool was installed. EAS Update now serves bsdiff patches: on by default from SDK 56 (opt-in with updates.enableBsdiffPatchSupport on SDK 55), and on SDK 58 with EAS Build it can also patch from the bundle embedded in the build. Expo's limits: a patch is precomputed only against the second-newest update on the channel, takes a few minutes to generate after publishing, and is served only when it is meaningfully smaller. Hot Updater lists bundle diffing too. Unchanged images and fonts aren't downloaded again with any of the three.

For the cost table above, patches mainly matter if you ship often or have a large bundle. A 10 MiB bundle shipped 8 times a month is 80 MiB per user without patches, which puts an EAS bill into paid bandwidth. With patches of about 8% it's under 7 MiB.

Rollouts and rollback

text
                    EAS Update                           CodePush model (Revopush,
                                                         CodePushNext servers)
staged rollout      --rollout-percentage on an update,   rollout percentage per release
                    or a branch rollout on a channel
mandatory update    your code decides (checkForUpdate    mandatory flag on the release
                    + reload)
manual rollback     eas update:rollback, to a previous   rollback command, or disable
                    update or the embedded bundle        the release
automatic rollback  fatal JS error in the first 10 s of  an update not confirmed with
                    a new update's first launch, before  notifyAppReady() (sync() does it)
                    content appeared: marked failed,     is rolled back on next restart
                    rolls back to the last good one
version matching    runtimeVersion: appVersion,          target binary version
                    fingerprint or a fixed string

A minimal EAS Update release flow, with the build's channel set in eas.json ("channel": "production" under the production build profile):

bash
npx expo install expo-updates
eas update:configure

# app.json: a new runtime version whenever native code changes
#   "runtimeVersion": { "policy": "fingerprint" }

# ship a fix to 10% of production builds
eas update --channel production --message "Fix checkout crash" --rollout-percentage=10

# watch crash rates, then raise to 50% and 100%
eas update:edit

# or undo it
eas update:revert-update-rollout
eas update:rollback                 # previous update or the embedded bundle
eas update:roll-back-to-embedded    # straight back to what shipped in the build

Use the fingerprint policy. The appVersion policy changes the runtime version only when you bump the app version, so a native change shipped without a version bump will receive JS updates it can't run. Fingerprint hashes everything that affects the native runtime. You'll make store builds more often, but an incompatible update can't reach a device. Only one rollout can run per branch, platform and runtime version, and a new rollout can't replace an active one until you finish or revert it (or pass --force-end-active-rollout, EAS CLI 24.9.0+).

The automatic rollback covers less than it seems. expo-updates only recovers from a fatal JS error in the first 10 seconds of a new update's first launch, before the app reports that content appeared. After that, it can only fix forward: it waits 5 seconds for a newer update, otherwise the app crashes. Expo also warns that rolling back can be unsafe if the bad update changed stored data in a way the old code can't read. Staged rollouts, and a fix you can publish fast, protect you more than the automatic rollback does.

Which one to pick

text
situation                                        pick
Expo app, under 1,000 updating users             EAS Update Free
Expo app, 3,000 to 39,000 MAU                    EAS Update Starter
Expo app, above 39,000 MAU, wants one vendor     EAS Update Production
  for builds and updates
bare RN app on codePush.sync() today             a CodePush-compatible service (Revopush)
                                                 or the CodePushNext client + your server
large audience, cost first                       Revopush, or Hot Updater if you can run it
no third-party update hosting allowed            Hot Updater on your own cloud account
native change, permission, icon, SDK upgrade     a store build, whatever you pick above

Whatever you pick, OTA updates only cover JavaScript, and the last row of that table still needs a signed build, a TestFlight or Play test track and a store review. Build minutes are priced separately. Our comparison of what iOS and Android builds cost on CI in 2026 covers that side. For the review and test step, see TestFlight in practice: tester limits, 90-day build expiry and beta review.

When a fix needs native code, it needs a new build. aipps.dev builds React Native, Android and iOS apps and gives every build a QR install page, so testers can check the native release while your OTA updates keep the JavaScript current.

See aipps.dev

Sources, read 26 October 2026: Apple Developer Program License Agreement, section 3.3.1(B) Executable Code (developer.apple.com/support/terms); App Review Guidelines 2.5.2 and 4.7; 9to5Mac, "Apple steps up crackdown on vibe coding apps" (30 March 2026); Google Play Console Help, Device and Network Abuse (support.google.com/googleplay/android-developer/answer/9888379); Expo pricing (expo.dev/pricing) and docs: Usage-based pricing, Rollouts, Rollbacks, Error recovery, Runtime versions, Optimize assets, Bundle diffing (docs.expo.dev/eas-update); Revopush pricing (revopush.org/pricing) and docs (docs.revopush.org); GitHub: microsoft/code-push-server and microsoft/react-native-code-push (archived 20 May 2025), CodePushNext/react-native-code-push, gronxb/hot-updater; Cloudflare R2 pricing (developers.cloudflare.com/r2/pricing) and Workers pricing. Bundle sizes: our own npx expo export of hello-aipps (Expo SDK 58, React Native 0.88), 26 October 2026.