I used to think about over-the-air updates as a simple shortcut: build a JavaScript bundle, publish it, and let the app download it. That picture did not survive contact with a real enterprise mobile setup.
In our case, the hard part is not running CodePush. The hard part is making sure the right bundle reaches the right app, for the right tenant, on the right native version, from the right release branch.
This is the setup I ended up trusting.
The release is identified by a deployment tag
The central object in our OTA flow is not "Production" or "Staging". It is a deployment tag, usually the same version-like value used by the release train, for example:
2026.07.01-preview-202026.07.01-rc-12026.07.01-hotfix-2
That tag is used as the CodePush deployment name and as the visible identity of the JavaScript bundle.
Before publishing the bundle, the pipeline rewrites a tiny file in the mobile app:
export const DEPLOYMENT_TAG = "2026.07.01-rc-1";
The app shows that value in its settings screen. It looks almost too small to matter, but it removed a lot of guesswork. When QA reports a bug, we can ask for the app version and the deployment tag and immediately know which JavaScript line the device is running.
Publishing is a two-platform matrix
The normal OTA publishing workflow accepts a deployment name and checks out the branch that should produce the bundle. Then it runs the same publishing action for both platforms:
- Android CodePush app
- iOS CodePush app
The action does four important things.
First, it logs into our CodePush management server using CI secrets. Second, it checks whether the deployment already exists. If the deployment is missing, the action creates it before publishing. That means a new preview, release candidate, or hotfix tag does not require a separate manual setup step.
Third, it signs and publishes the React Native bundle:
code-push-standalone release-react <app> <platform> \
--deploymentName "$DEPLOYMENT_NAME" \
--description "Version: $VERSION" \
--targetBinaryVersion "$TARGET_BINARY_VERSION" \
--privateKeyPath codepush_private_key.pem \
--mandatory
Fourth, it uploads source maps to monitoring. That part is easy to underestimate. An OTA bundle without matching source maps turns production debugging into archaeology. In our flow, Datadog gets source maps for both platforms, and Sentry gets the iOS bundle mapping as well.
Native compatibility is explicit
Every CodePush bundle is published with a targetBinaryVersion. In our workflow this value is not a vague note in a runbook; it is an actual release parameter used by the publishing command.
That matters because a JavaScript bundle is only safe for the native binaries it was built against. If a bundle calls a native module that does not exist in an older store build, CodePush can make the app worse instead of better.
So I treat native compatibility as a contract:
- If the JS works on the current store build and the new store build, the target range can be widened.
- If the JS requires the new native build, the range must be narrowed.
- If the native version changes after a store release, the OTA metadata must be patched deliberately.
For that last case we have a separate workflow. It takes a deployment tag and a semver expression, then patches the latest CodePush release for both Android and iOS:
code-push-standalone patch <app> <deployment> \
--targetBinaryVersion "2.3.0 - 2.4.0"
That separation is important. Publishing a new bundle and changing the compatibility window are different operations. Keeping them as different workflows makes the release decision visible.
Native release branches get their own OTA path
The most specific part of the setup is native release orchestration.
For native release work, we do not publish OTA directly from whatever branch someone has open locally. The workflow expects a native release sub-branch, for example:
native-release/2.3.10/main
The workflow validates that the branch exists, derives the original source branch from it, merges the source branch into the native sub-branch, and only then builds the OTA bundle.
That merge step is there for a practical reason: native release branches can live long enough to drift. If we publish JavaScript from a stale native branch, we may ship a bundle that misses a fix already present in the active release line.
The workflow also posts a failure message when the merge cannot be completed automatically. That sounds operational rather than architectural, but it changes behavior. A failed merge becomes a release task, not a silent difference between branches.
The app gets deployment keys from configuration
The native binary contains CodePush wiring, public keys, and a default deployment key, but the runtime decision comes from tenant configuration.
The configuration has two shapes:
activeCodepushDeploymentKeys: {
deploymentName: string;
codepushUrl: string;
ios: string;
android: string;
} | null;
codepushDeploymentKey: {
ios: string;
android: string;
};
The app prefers activeCodepushDeploymentKeys because it carries both the platform key and the server URL. If that field is not present, it falls back to the legacy codepushDeploymentKey.
This is what allows one binary to serve multiple tenants and environments without rebuilding the app. The same installed app can point to a different OTA deployment after the user selects a different tenant environment, because the app receives different configuration.
Runtime update flow is guarded and observable
On startup, the app waits for tenant configuration. Once it has the CodePush key for the current platform, it runs a guarded update flow:
- Notify CodePush that the current bundle is ready.
- Check for an update with a short timeout.
- If no update exists, mark the result as
no_update_available. - If an update exists, download it with a longer timeout.
- Install immediately or schedule for next restart, depending on the wrapper mode.
- Record status, timing, package size, and errors in monitoring.
The timeout values are intentionally asymmetric. Checking for an update should be quick. Downloading a real bundle needs more time, especially on mobile networks.
The app records custom monitoring actions such as:
codepush.check_updatecodepush.downloadcodepush.installcodepush.install.scheduledcodepush.unexpected_error
Those events make OTA visible after release. Without them, CodePush is just a black box between CI and the user's device.
Silent push is only a trigger
We also support a push-driven update path. The push payload marks that a new deployment exists and includes the platform deployment key. The app first checks whether the payload matches the current tenant and environment. If it does not match, it ignores the push.
If it matches, the app checks and downloads the update in the background with a strict timeout. The important detail is that the push does not carry secrets or business logic. It is only a "please check now" signal.
That distinction keeps the flow understandable. CI owns publishing. Configuration owns deployment keys. Push only reduces the delay before a device asks for the latest bundle.
The operating model
The actual approach is smaller than a generic OTA strategy:
- Use deployment tags as the source of truth for what was published.
- Publish Android and iOS together from the same branch and tag.
- Create missing CodePush deployments automatically during publishing.
- Stamp the bundle with the deployment tag before building it.
- Sign every bundle.
- Upload source maps as part of the same release action.
- Treat
targetBinaryVersionas a compatibility contract, not metadata. - Patch native ranges through a separate workflow after native releases.
- Let tenant configuration decide the active deployment key at runtime.
- Make the app report check, download, install, and failure states.
The setup is not fancy. It is mostly a set of boring constraints that prevent expensive mistakes.
What I would keep if I rebuilt it
If I had to rebuild this flow from scratch, I would keep three ideas.
First, I would keep the deployment tag visible inside the app. It is the fastest way to connect a device report with a CI release.
Second, I would keep native compatibility as an explicit range that can be patched independently from publishing. Native and JavaScript releases move at different speeds, and pretending otherwise creates release pressure at the worst time.
Third, I would keep runtime observability close to the update code. OTA failures rarely look dramatic to users. Often the app simply keeps running an older bundle. If the app does not report update results, the release team discovers the problem too late.
OTA is valuable because it shortens the path from fix to user. In a multi-tenant React Native app, that speed only stays safe when the release tag, deployment key, native version, and tenant configuration all point to the same story.