Mobile Apps

OTA Updates: What Ships Without Another App Review

Examine OTA update mechanics in React Native and Flutter: what ships through the app store, what bypasses review, and where the limits are.

Mohammed Saqib10 min read
Black woman programming on a laptop with coffee, smartphone, and glasses on a desk in an office.
Photo by Christina Morillo on Pexels · Pexels License

Every mobile team hits the same wall eventually: a production bug that needs a one-line fix, and the only path to users is a full store submission, a review queue, and a rollout that takes days. Over-the-air updates exist to close that gap, but the mechanics differ sharply between React Native and Flutter, and the platform rules are narrower than most people assume. For a deeper comparison of the two frameworks, see Flutter versus React Native: Choosing by Team Shape. If you understand what actually ships through the OTA channel, you can push hotfixes in hours instead of waiting on review—and avoid the kind of update that gets your app pulled.

What OTA can and cannot touch

The boundary is set by what your framework compiles to at runtime. In React Native, the JavaScript bundle and static assets are loaded by a native shell. An OTA update replaces that bundle and those assets, so any change to Swift, Kotlin, or Java code is out of scope. You can fix a logic error in a Redux reducer, tweak a style object, or swap the text on a button. You cannot add a new native module, change the New Architecture configuration, or bump the minimum iOS version—those require a rebuild and a store submission.

Flutter takes a different route. The Dart code is compiled ahead of time into a native snapshot, so there is no script to swap at runtime. OTA in Flutter, as implemented by Shorebird, works by generating a binary diff between the current and new Dart snapshot. The app applies that patch at startup, which means you can update any pure Dart logic. You cannot update the Flutter engine itself, native plugins, or platform-specific code written in Kotlin or Swift. The patch is tightly coupled to the exact engine version your app was built with.

The platform rules sit on top of these technical limits. Apple’s guideline 3.3.2 permits OTA updates for bug fixes, but explicitly forbids using them to change the app’s core functionality. If your update adds a feature that alters the app’s purpose, you are in violation. Google’s stance is more permissive on the surface, but they still flag updates that change security-sensitive behavior, like certificate pinning or payment flows. In practice, both stores expect OTA to be a hotfix channel, not a delivery mechanism for new features.

React Native: CodePush and EAS Update compared

CodePush, now part of App Center, is the classic option. You upload a bundle to a deployment key—typically separate Staging and Production keys—and the app checks for updates on launch. The flow is straightforward: code-push release-react MyApp ios packages the JS bundle and pushes it to the target key. The client downloads the bundle in the background, then applies it on the next restart. You can mark an update as mandatory, which forces the app to apply it immediately, and you can roll back from the dashboard by releasing the previous bundle again.

Expo’s EAS Update is the newer contender, and it fits naturally if you are already using EAS Build. It works through a manifest that maps a runtime version to a bundle. When your app launches, the expo-updates library fetches the manifest, checks the runtime version against the native binary, and downloads the matching bundle if one exists. Rollback is a dashboard action, and you can target updates by channel, which maps directly to your build profiles. The trade-off is that EAS Update requires the expo-updates library and a bit more setup around runtime versions, but it avoids the App Center dependency that has felt increasingly orphaned since Microsoft shifted focus.

Here is what a minimal EAS Update configuration looks like in your app.json:

{
  "expo": {
    "runtimeVersion": "1.0.0",
    "updates": {
      "url": "https://u.expo.dev/your-project-id",
      "enabled": true,
      "fallbackToCacheTimeout": 30000
    }
  }
}

And the corresponding CLI command to push an update to a specific channel:

eas update --channel production --message "Fix null pointer in checkout flow"

The choice between the two comes down to your build pipeline. If you are already on EAS for builds, EAS Update removes a whole class of version-mismatch problems because the runtime version is embedded in the native binary. CodePush is more battle-tested, but its future is murkier, and the App Center portal has not seen meaningful improvements in years. For a new project, I would lean EAS Update unless you have a hard requirement to avoid the Expo ecosystem.

Flutter: Shorebird and the patch model

Shorebird is the only serious OTA option for Flutter that does not require you to rewrite your app as a hybrid. Its approach is fundamentally different from CodePush because there is no script to swap. Instead, Shorebird hooks into the Flutter build process and produces a patch that contains only the bytes that changed in the AOT snapshot. The client applies that patch at startup, before the Dart isolate runs, so the update is effectively atomic from the user’s perspective.

The advantage is size. A typical React Native OTA bundle is a few megabytes, but a Shorebird patch is often tens of kilobytes for a small logic change. The disadvantage is coupling. The patch is tied to the exact Flutter engine version and platform architecture. If you upgrade the Flutter SDK, you must do a full release—there is no OTA path across engine versions. The same applies to native plugin changes; any modification to the platform channel requires a store submission.

Shorebird is a commercial product, and that is the main cost. You are trading vendor lock-in for not having to build your own patching infrastructure, which is a substantial engineering effort. The SDK handles patch verification, rollback, and reporting out of the box. If you are on a team that already uses Flutter for production apps, the pricing is usually justifiable against the cost of a full release cycle for a trivial fix. But you should be aware that you are betting on Shorebird’s long-term viability, and there is no open-source fallback that matches its feature set.

Update mechanics and rollout controls

Both CodePush and EAS Update give you the same core controls: percentage rollout and mandatory updates. You can push a bundle to 10% of your production users, monitor crash reports, then ramp to 100% over a few hours. Mandatory updates override the background-download-and-restart flow, forcing the user to apply the update before continuing. This is critical for a fix that addresses a data-corruption bug or a security vulnerability.

The update lifecycle differs slightly between the two frameworks. React Native downloads the bundle in the background, then applies it on the next cold start. This means a user in the middle of a session will not see the fix until they restart the app. Shorebird patches apply at startup as well, so the same caveat applies. There is no way to hot-swap a running Dart isolate or a React Native bridge without a restart, so you cannot fix an in-memory state issue mid-session.

Rollback is where you need discipline. In CodePush, you can release the previous bundle to the same key, and the client will pick it up on the next check. In EAS Update, you can roll back to a previous update from the dashboard. Shorebird has a similar revert mechanism. The failure mode to avoid is rolling back to a bundle that has a different runtime version than what your native binary expects. If you forget to bump the runtime version when you add a native module, the client will reject the bundle, and you will be stuck with a broken release and no OTA path to fix it.

Failure modes and gotchas

The most common failure is a bundle mismatch caused by forgetting to bump the runtime version or deployment key. In React Native, if you add a dependency that includes native code, the JS bundle alone is not enough—the native binary must be rebuilt. If you push a bundle that references a new native module, the app will crash on startup with a red screen or a native exception. The same applies to New Architecture changes. Fabric and TurboModules require the native side to be compiled with matching flags, and an OTA update cannot change that. If you are adopting the New Architecture, treat it as a native change that requires a full release, not an OTA candidate. I have written about the migration path separately in Adopting React Native New Architecture: Fabric and TurboModules, and the OTA constraints are a good reason to sequence that work carefully.

In Flutter, the equivalent failure is a patch that does not match the engine version. Shorebird will reject a patch built against a different Flutter SDK, but the error message can be cryptic. The practical rule is to pin your Flutter SDK version in CI and never upgrade it in a hotfix branch. Asset handling is another gotcha: if your OTA bundle references an asset that was not included in the upload, the app will fail at runtime when it tries to load the missing file. Both CodePush and EAS Update include assets in the bundle, but you need to verify the asset manifest matches what the native binary expects, especially if you are using a custom font or a large image set.

Testing and CI integration

OTA updates need the same rigor as a store release, but the stakes are higher because there is no review gate. The minimum is an end-to-end test against a staging deployment key that runs on both iOS and Android. Your CI should build the app with the staging key, push the update, then run a smoke test that launches the app, verifies the bundle is applied, and checks a few critical flows. This catches the common failure of a bundle that parses but crashes on a specific platform. Ensuring consistent build profiles across CI and local development is covered in Expo EAS Build Profiles That Keep CI Reproducible, which helps avoid breakpoints due to mismatched runtime versions.

A canary group is non-negotiable for production rollouts. Push to 5% of users, monitor your crash reporting and a health endpoint that your app pings after applying an update, then ramp. The health endpoint is the key piece: it should report the app version, the OTA update ID, and a flag that indicates whether the update applied successfully. If the crash rate spikes or the health endpoint stops reporting, you roll back from the dashboard before the majority of users are affected.

Here is a simple CI script that pushes an update to a staging channel after tests pass:

#!/bin/bash
set -euo pipefail
 
# Build the JS bundle and run tests
npm run test
npm run build:ios
npm run build:android
 
# Push to staging channel
eas update --channel staging --message "CI: $(git log -1 --oneline)"

The practical limit is that OTA updates are not a substitute for a release pipeline. They are for hotfixes, not feature delivery. Store review still gates native changes, and if you rely on OTA for everything, you will eventually ship a feature that requires a native dependency and get blocked. Use OTA to keep your users unblocked on critical bugs, and treat it as a complement to your normal release cadence, not a replacement. For teams that need to move fast without waiting on review, the setup is worth the effort—but only if you respect the boundaries of what the platform actually allows.

Frequently asked questions

How do OTA updates work in React Native versus Flutter?
React Native uses CodePush (or EAS Update) to swap JavaScript bundles at runtime, while Flutter relies on third-party tools like Shorebird that patch the Dart AOT snapshot. The core difference is that React Native's update is a bundle replacement, whereas Flutter's is a binary patch applied to compiled code.
What can you legally ship via OTA on iOS?
Apple's guidelines prohibit OTA updates that change app functionality without review, but they permit bug fixes and content updates when done via the configured OTA mechanism. In practice, teams use OTA for critical fixes, but they should keep native code changes in store releases to stay within the rules.
What are the common pitfalls when shipping OTA updates?
For React Native, avoid changing native modules or the New Architecture version in an OTA update, and test the update on both platforms before release. For Flutter, Shorebird patches only Dart code, so any change to plugins or native code requires a full app release.
#react-native#flutter#ota-updates#app-store#shipping
Share

Keep reading