This article is written by Zach Goldie
CodePush is a great way to ship over-the-air (OTA) updates, avoid app store approval delays, and roll out changes cautiously. Even though App Center has closed down, there are many options available to get started with CodePush.
But some of the default settings can create undesirable behaviors, leaving teams wrongly thinking CodePush causes a bad user experience.
Two common patterns can appear from these defaults when trying CodePush:
Updates never activating - CodePush will default to using ON_NEXT_RESTART for optional updates unless you override it, which waits for a full app restart. Since many applications aren’t frequently restarted or relaunched, updates can be downloaded but sit pending for days without activating.
Installs feel like crashes - Teams often switch to ON_NEXT_RESUME, but by default it will apply after even a momentary background/foreground cycle. The default IMMEDIATE behavior for mandatory updates will similarly cause the install to interrupt the user. Both will drop users out of their current flow and briefly show a blank/white screen.
These defaults are fine for internal builds and experiments, but they usually need tuning for production UX.
If you’re using or considering CodePush, this guide will help you implement one of two production-ready workflows:
- Pattern A: Silent background updates
- Pattern B: Updates with in-app approval
Each avoids common UX and reliability pitfalls.
Two patterns that work in production
Most teams end up with one of two approaches, depending on their preferences around interruptions and how visible updates should be.
Pattern A: Silent background update
Best for apps where visible interruptions are undesirable (games, media, long in-app flows). The example flow will check for updates on resume, and apply them while the app is in the background to avoid abrupt in-session restarts.
Options this pattern uses -
checkFrequency: ON_APP_RESUMEchecks for updates each time the app returns to foreground.minimumBackgroundDuration: 60 * 10sets a background duration in seconds before activation is allowed.installMode: ON_NEXT_SUSPENDapplies the update while the app is off-screen, after that minimum background time.
import CodePush from '@code-push-next/react-native-code-push';
function App() {
return <YourApp />;
}
export default CodePush({
checkFrequency: CodePush.CheckFrequency.ON_APP_RESUME,
minimumBackgroundDuration: 60 * 10, // 10 minutes
installMode: CodePush.InstallMode.ON_NEXT_SUSPEND,
})(App);
A trade-off is that the UX stays smooth because activation happens while the app is already off-screen. If users keep the app open in the foreground, activation can be delayed until they background it for long enough. If you want an even more conservative option, ON_NEXT_RESTART waits for the next true app restart boundary.
This baseline Pattern A example focuses on optional updates. If you also ship mandatory releases, explicitly set mandatoryInstallMode to match your UX policy (for example, ON_NEXT_SUSPEND to stay silent, or IMMEDIATE for urgent fixes that should interrupt the session). See the sync options doc for more details.
Pattern B: Informed update with user confirmation
For this pattern, we’ll keep the IMMEDIATE nature of a mandatory update, but add the user into the loop so they’re less of a surprise. This flow will show update details and ask users for confirmation, and paired with restart controls in critical flows.
Options this pattern uses -
installMode: IMMEDIATEandmandatoryInstallMode: IMMEDIATEapply updates as soon as they are installed.updateDialogfields (title + button labels) define the user prompt and available actions.appendReleaseDescription: trueanddescriptionPrefixinclude the release message in that prompt.
import CodePush from '@code-push-next/react-native-code-push';
import { useEffect } from 'react';
function App() {
useEffect(() => {
codePush.sync(
{
installMode: CodePush.InstallMode.IMMEDIATE,
mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE,
updateDialog: {
title: 'Update available',
optionalInstallButtonLabel: 'Install now',
optionalIgnoreButtonLabel: 'Later',
appendReleaseDescription: true,
descriptionPrefix: "What's new:\n",
},
},
(status) => {
if (status === CodePush.SyncStatus.DOWNLOADING_PACKAGE) {
setShowProgress(true);
}
if (status === CodePush.SyncStatus.UPDATE_INSTALLED) {
setShowProgress(false);
}
},
({ receivedBytes, totalBytes }) => {
setDownloadProgress(receivedBytes / totalBytes);
}
);
}, []);
return <YourApp />;
}
export default CodePush({ checkFrequency: CodePush.CheckFrequency.MANUAL })(App);
To make that release text useful, publish each update with a clear release description in your CodePush release command (for example, a short “what changed” summary and any user-facing notes).
In most setups, this means passing --description, but CLI options can vary by version. Run code-push release-react --help to confirm the exact flag name in your installed CLI. See Releasing updates.
Critical-flow protection example (avoid restarts mid-action):
// User starts onboarding
CodePush.disallowRestart();
// Onboarding complete
CodePush.allowRestart();
A trade-off with this pattern is that users can defer optional updates indefinitely. Use mandatory releases for urgent fixes.
Adding a custom splash/loading screen
One way default CodePush implementations can feel like crashes is the brief blank screen or white flash that can appear while an update is being applied.
To make updates feel like a normal startup experience, cover the entire update lifecycle with a loading screen, not just the install step. In other words, keep the app on a branded loading state during:
- update check
- package download
- install
- app restart boundary
This pattern intentionally trades a longer startup for a more predictable UX. The restart still happens under the hood with IMMEDIATE, but users experience it as part of startup rather than as an interruption mid-session.
import CodePush from '@code-push-next/react-native-code-push';
import { useCallback, useEffect, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
function App() {
const [bootstrapping, setBootstrapping] = useState(true);
const checkAppUpdates = useCallback(async () => {
try {
// Critical: mark the current bundle as successful to avoid rollback.
await CodePush.notifyAppReady();
const remote = await CodePush.checkForUpdate();
if (remote) {
const local = await remote.download();
await local.install(CodePush.InstallMode.IMMEDIATE);
return; // App restarts before we clear the loader.
}
} catch (error) {
captureError(error); // Replace with your own error reporting.
} finally {
setBootstrapping(false);
}
}, []);
useEffect(() => {
checkAppUpdates();
}, [checkAppUpdates]);
if (bootstrapping) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#0B1F46' }}>
<ActivityIndicator color="#fff" />
<Text style={{ color: '#fff', marginTop: 12 }}>Checking for updates...</Text>
</View>
);
}
return <YourApp />;
}
export default CodePush({
checkFrequency: CodePush.CheckFrequency.MANUAL,
})(App);
For notifyAppReady(), see Issues and debugging.
You can replace this React Native loading UI with your native splash screen if you already use one.
If the update applies so fast that the loading screen only appears as a brief flicker, consider enforcing a minimum overlay duration of a second or two before hiding it.
Additional fine-tuning options
Once you’ve chosen a baseline pattern, these options help with the remaining “production polish.” They are all available in @code-push-next/react-native-code-push, a maintained CodePush fork, and work with Codemagic’s hosted CodePush service. See Advanced: sync options.
downloadProgressCallback
Track receivedBytes and totalBytes during download to show progress feedback, especially when sync() runs while the app is on screen.
codePush.sync(
{ installMode: CodePush.InstallMode.ON_NEXT_RESTART },
(status) => {
// replace with your real status handling
},
({ receivedBytes, totalBytes }) => {
const progress = receivedBytes / totalBytes;
setDownloadProgress(progress);
}
);
handleBinaryVersionMismatchCallback
Handle users whose binary is too old for the update by showing an app store upgrade prompt, turning silent mismatch failures into a clear next step.
codePush.sync(
options, // replace with your real sync options
statusCallback, // replace with your real status callback
progressCallback, // replace with your real progress callback
(update) => {
showStoreUpdatePrompt(); // implement your own UX
}
);
First run and pending updates (isFirstRun, isPending, getUpdateMetadata)
After an update is installed, local CodePush package metadata includes:
isFirstRun: useful for showing a one-time “What’s new?” UI after a new bundle becomes active.isPending: indicates there is still an update waiting to take effect (for example, when install modes defer activation).
You can also call CodePush.getUpdateMetadata(updateState) with CodePush.UpdateState.PENDING or CodePush.UpdateState.RUNNING to inspect metadata even if you are not relying on the object returned from sync().
// inside an async function
const pending = await CodePush.getUpdateMetadata(CodePush.UpdateState.PENDING);
const running = await CodePush.getUpdateMetadata(CodePush.UpdateState.RUNNING);
if (running?.isFirstRun) {
// show “What’s new?” for this bundle
}
if (pending?.isPending) {
// there's an update waiting to be applied
}
Prefer getUpdateMetadata in newer SDK versions where getCurrentPackage is deprecated.
One behavior to note: mandatory update propagation
Mandatory status propagates through deployment history.
If any release between a user’s current version and latest is mandatory, the latest package can be enforced as mandatory, even if the latest itself was published as optional.
Example:
- v1: optional
- v2: mandatory (critical fix)
- v3: optional (new feature)
A user on v1 may receive v3 as mandatory because v3 includes the mandatory v2 fix.
This is expected behavior, so be deliberate about what you mark mandatory. If needed, you can adjust release metadata later using your CodePush patch tooling. See Production control.
Before pushing to production
- Test timing on real devices so activation happens at the lifecycle boundary you expect.
- If using
ON_NEXT_RESUME, setminimumBackgroundDurationso brief app switching doesn’t trigger activation. - Confirm mandatory behavior matches your intent, including propagation from earlier mandatory releases.
- Verify the dialog copy and release-note text users will actually see.
- Guard genuinely critical flows with
disallowRestart/allowRestart.
A reliable CodePush strategy is less about “turning OTA on” and more about creating an update experience that matches how users actually use your app. With the right flow and guardrails, you can ship faster without making updates feel risky.
Want to use CodePush for your app?
Try our hosted CodePush service for your OTA needs. It’s a fully maintained fork of the original Microsoft service, with simple pricing and 99.9% deliverability.
For more details, see the CodePush feature page, the setup docs, and Advanced: sync options.
