How to Take a Fintech Product from Web to Mobile
Every web-to-mobile project I've been on had the same scoping meeting. Someone opens with "we already have the API, so mobile is mostly UI," the room nods, and a quarter gets pencilled in.
That sentence hides four things: the read contract your web app grew up with, the auth model that lives in a cookie, the OS plumbing a browser gave you for free, and a release cycle where "ship the fix" meant a redeploy, not a store review.
Most of the work lands behind the screens. The mobile client is the cheap part; what costs money is everything the web app was allowed to assume. This is about re-negotiating those assumptions, with shipped projects as evidence.
Decide what parity means before you scope
"Parity" is the default nobody chooses. It arrives because nobody wrote down a narrower target, and it quietly becomes the acceptance criterion.
There are three defensible targets, and you should pick one out loud:
1. Companion app. Read everything, do one core action (approve a payment, sign a transaction, check a balance). Cheapest, and often the right first release.
2. Functional parity. Every flow on web works on mobile. This is what most stakeholders mean when they say "the app."
3. Mobile-first rebuild. Mobile is a net-new surface with its own flows, and web follows later.
Two projects show the ends of that spectrum. On Moneygate, a payment provider regulated by the Central Bank of Cyprus, the brief was explicitly a mobile app replicating all the functionality of the web platform: payments, multi-currency, fraud prevention, reporting, analytics.
Parity was a stated requirement, and a five-person team delivered it: two React Native devs, a QA engineer, a designer and a business analyst. The BA mattered; the case page counts more than 22 business flows and features that had to be documented before they could be scoped.
Ctrl, the wallet formerly known as XDEFI, went the other way. The product existed only as a web version and a browser extension, and "no mobile access" was the first problem on the list. Mobile there was new surface, not a port.
One limitation to accept up front: parity is measured in flows, not features. A web screen with six optional filters and a CSV export is not one mobile screen; it's three, plus a decision about which filter combinations deserve a bottom sheet.
Your web API is the first thing to break
Web APIs evolve under assumptions mobile invalidates:
- Many small round trips are fine, because the tab sits on Wi-Fi.
- Large payloads are fine, because the page renders whatever arrives.
- Offline is a non-case; the user reloads.
- Old API versions die the moment you redeploy, because every client reloads on the next visit.
Mobile invalidates all four, and the last one hurts most. The moment you ship a binary, you own a population of clients you cannot force-upgrade.
In June 2025 Apple's own numbers put iOS 18 on 82% of all active iPhones, nine months after release: one in five devices still on an older OS. Android is worse; April 2026 Statcounter data compiled by apilevels shows Android 11 or newer on 86.9% of devices, with the newest release at 22.3%.
Your app's update curve follows the same shape, so a /v1 endpoint you'd love to delete will get traffic for a year.
Chatty endpoints, cold starts and the unified read
The practical fix is server-side aggregation and deliberate versioning. Android vitals flags a cold start of 5 seconds or longer as excessive, and a home screen that fans out to eight REST calls on a cellular connection eats that budget before the first balance renders.
DEFI Wallet, a non-custodial multichain wallet spanning 20 blockchains, is the clearest example of building the read contract once. Its backend is a set of microservices behind a GraphQL router that combines their schemas: blockchain indexers for real-time transactions, a Python service for token metadata, and Node.js/Nest.js Asset and NFT balance services.
That single graph serves the browser extensions (Chrome, Edge, Opera, Brave), the web app and the iOS/Android apps. Mode, a UK-listed crypto company with GBP, EUR and BTC accounts, used microservices for the same reason across its mobile backend and admin portal, exposing GraphQL to both.
What that looks like from the client side is one query per screen, not one per widget:
One round trip for the portfolio screen; the router fans out server-side
query PortfolioScreen($account: ID!, $fiat: Currency!) {
account(id: $account) {
balances(fiat: $fiat) { asset { symbol chain } amount fiatValue }
nfts(first: 20) { edges { node { id name imageUrl } } }
recentTransactions(first: 10) { hash status timestamp }
}
}
The thing to look at is the first: arguments. Pagination limits that were optional on web become mandatory on mobile, because the alternative is a 4 MB response on a train.
The limitation is real, though. A unified API is a coupling decision: one aggregation layer serving three clients becomes the bottleneck every team queues behind, and a schema change for the extension now has a mobile review attached to it.
In the 2024 State of React Native survey, 59.3% of respondents use TanStack Query and 34.2% use Apollo Client. Whichever you pick, the server contract decides how many requests a screen makes, not the client library.
State management doesn't survive the trip
Redux-shaped web state is tuned for a tab that reloads on demand. A mobile app backgrounds, loses the network in a lift, and resumes four hours later with a store full of stale balances and an expired token. The state model has to separate two things the web app blurred:
- Server state: cache, refetch, invalidation, background sync. Belongs to a query library.
- Client state: navigation, form input, UI toggles. Belongs to a small store, persisted deliberately.
Moneygate used React Query for server state under a server-state pattern, with React Hook Form for input. Ctrl runs Redux Toolkit with Redux Persist in the extension but Zustand in the React Native app. DEFI Wallet made the same split: Redux on web, Zustand on mobile.
That is three teams independently accepting two state libraries in one product. They're not alone: Redux is still used by 71% of React Native respondents and Zustand by 46.7%, with Redux complexity the third most-cited pain point. Two stores is a maintenance tax; the case evidence shows teams paying it, not that it's free.
The part that bit us was hydration ordering. A persisted store that rehydrates after the first render shows a logged-out screen for one frame, then flips, and on a payments app that flash is a support ticket. Gate rendering on hydration explicitly:
// Zustand + persist: don't render auth-dependent screens until the store is back
export const useSession = create<SessionState>()(
persist(
(set) => ({ token: null, hydrated: false, setToken: (t) => set({ token: t }) }),
{
name: 'session',
storage: createJSONStorage(() => secureStorage), // not AsyncStorage for tokens
onRehydrateStorage: () => (state) => state && (state.hydrated = true),
},
),
);
The comment on storage is the point. AsyncStorage is used by 83.8% of respondents in the same survey, and it is plaintext. Session material goes in the keychain or keystore; everything else can go in AsyncStorage or MMKV.
Platform plumbing is the backlog nobody writes down
A browser handed the web app downloads, PDF viewing, printing and camera access, and none of it appeared in a ticket. On mobile each is an integration with its own permission model and OS-version behaviour. Budget them as features, not tasks.
Files, PDFs and the Android storage rules
Moneygate's challenge list included secure file downloads on Android and PDF preview with actions. Neither is exotic on web. On Android, apps targeting Android 11 or later run under scoped storage; the system ignores the requestLegacyExternalStorage flag, WRITE_EXTERNAL_STORAGE grants nothing extra, the Storage Access Framework refuses ACTION_OPEN_DOCUMENT_TREE on the Download directory, and declaring All Files Access "can affect your ability to publish and update your app on Google Play."
So the web app's <a download> became, on Moneygate:
- expo-file-system with the Storage Access Framework for user-directed saves.
- rn-pdf-reader-js for in-app PDF preview with actions.
- react-native-gesture-handler plus bottom-sheet for full-screen modals replacing traditional buttons.
- Reanimated for animation performance, because a statement list that stutters reads as a broken app.
Soapbox, a fulfilment-tech company's Android warehouse app, hit the other classic: React Native library compatibility problems implementing barcode scanning, settled with vision-camera-code-scanner and react-native-print.
Every one of those libraries carries native code, narrows your upgrade path and becomes a dependency you own through every SDK bump. The 2024 survey put New Architecture adoption at almost 50% with library compatibility the standing concern; the PDF viewer you picked in year one is exactly what blocks you in year two.
Biometrics are an unlock, not an authentication
This is the argument I have most often with product owners. Face ID does not log a user in. It gates something on the device, and the server should never learn or care that a biometric happened.
Treat it as a local factor:
1. The server issues a session or refresh token after real authentication (password, passkey, OTP, whatever your risk model requires).
2. The token sits in the keychain or keystore, wrapped by a key that requires user presence.
3. Biometrics unlock that key. Server-side authorization is unchanged and still expires and revokes on its own schedule.
Android's docs separate BIOMETRIC_STRONG (Class 3) from BIOMETRIC_WEAK (Class 2) and provide CryptoObject so a successful prompt unlocks a Cipher or Signature rather than returning a boolean. OWASP lists cryptographic binding as a best practice for exactly that reason: a boolean callback can be bypassed or replayed; a keystore key with setUserAuthenticationRequired(true) cannot be used without the prompt succeeding.
Moneygate used expo-local-authentication for biometric authorization. That library gives you the prompt; the key-wrapping around it is your design.
Users want this. In the FIDO Alliance's 2024 barometer, biometrics ranked as the login method consumers consider both the best experience and the most secure for the second year running, and 42% had abandoned a purchase in the previous month over a forgotten password. That demand is exactly why teams are tempted to let the prompt stand in for auth.
Custody and secrets change shape on mobile
Custodial fintech has the easy version: the secret is a session token, and the worst case is re-login. Non-custodial products have the hard version. The key material lives on a device you don't control, next to a screenshot tool, a clipboard and whatever the user side-loaded.
Ctrl and DEFI Wallet are both non-custodial. DEFI Wallet's case page states it does not store users' seed phrases or passwords, and it ships the same key operations across extensions, web and mobile, with Ledger hardware-wallet support as an alternative to on-device keys.
What I can offer as engineering practice, kept separate from what those clients published:
- Generate and use keys inside the platform's hardware-backed store; export only for recovery flows, never for convenience.
- Bind signing to Class 3 biometrics or a device credential, with explicit confirmation per transaction, not per session.
- Invalidate biometric-bound keys when enrolment changes, another OWASP best practice for the same reason.
- Design recovery as a first-class flow. A wallet whose recovery path is "the user wrote the phrase down" has outsourced its most dangerous UX to a napkin.
No source describes Ctrl's or DEFI Wallet's key storage, recovery or attestation design, so don't read that list as their architecture. It's the checklist the shipped surface implies you'll need.
Reuse what's shared, fork what isn't
The shared-code fantasy: one monorepo, one business-logic package, two thin UI shells. In practice the reuse band is narrower:
- Design tokens. Colours, spacing, typography. Cheap to share, high payoff.
- Components. Worth sharing when the primitive is the same on both platforms; the layout around it usually isn't.
- Validation schemas. Zod or equivalent; IBAN rules don't change with screen size.
- Business logic. Partial at best. The moment a flow touches storage, auth or navigation, it diverges.
Ctrl's problem list named component inconsistency across platforms as something slowing development, and the answer was a unified component library for web and mobile, with the extension on Tamagui and Zod. DEFI Wallet listed accelerating web development through component reusability as an explicit challenge.
Both teams treated the component layer as the reuse target. Neither claims they shared the business logic.
The cost is coupling. A shared library forces one release cadence on two platforms with different constraints: the extension can ship a fix the same day; the app cannot. That's the price of consistency, and it's worth paying only if you also set up the release side.
Shipping to a store is not shipping to a URL
This is the shift teams underrate. Store review, staged rollout and users on months-old binaries change how you branch, feature-flag and hotfix.
The numbers are better than they were, but they're not a redeploy:
- Apple says 90% of submissions are reviewed in under 24 hours. The other 10% is where your Friday incident lives.
- Google Play's staged rollouts let you halt a release, and the percentage never increases on its own; you go up, you don't go down.
- Apple's guideline 2.5.2 says apps may not download or execute code that introduces or changes features. OTA JavaScript updates live inside the interpreted-code exception and the platforms' content rules; Expo's own docs are blunt that EAS Update handles non-native pieces such as JS, styling and images and anything native, any permission change, any SDK bump needs a new build.
Soapbox is what it looks like when none of this is set up. The app arrived with no cloud-based JS update system, no automated code review or deployment, no separation between development, staging and production, frequent push conflicts, and manual Play Store updates required from users.
The fix was environment stratification via GitLab and CodePush, ESLint, Datadog and full-cycle testing, followed by more than 15 successful releases. Fifteen is a count, not an improvement measure, but it's fifteen more than a team with no pipeline ships. Mode's case page lists 23.
Two more things that shape release engineering, both easy to miss at scoping:
1. Your OTA vendor is a dependency. Microsoft retired App Center and CodePush on March 31, 2025 and published a standalone server to self-host. Adopt CodePush in 2023, inherit a migration in 2025. In the 2025 State of React Native survey, EAS Build is at 68.2% and GitHub Actions at 61.9%; the ecosystem consolidated, and that's a bet on someone else's roadmap.
2. The web side doesn't escape platform-mandated rewrites. Ctrl's extension had to migrate from Manifest V2 to V3. Chrome disabled MV2 for all users on all channels by March 2025, removed the re-enable switch with Chrome 138, and deletes the remaining MV2 extensions from the Web Store on August 31, 2026. An extension is a store-distributed client with its own review; teams that file it under "web" get surprised.
Set up environments and OTA before the first store submission, not after the first incident. After the first incident you'll do it anyway, under worse conditions.
What the move actually costs
Stated plainly, going web-to-mobile buys you:
- Two more runtimes with their own crash signatures.
- Two more release channels, each with review and rollout mechanics.
- A device and OS-version test matrix that grows every September.
- Permanent version skew, so every API change is a compatibility decision.
- A native-dependency surface (files, PDF, camera, biometrics) you upgrade by hand.
None of that is a reason not to do it. Moneygate's five-person team and Ctrl's twelve-person dev and QA team show the headcount range between parity and net-new surface. Ctrl reports a user base expansion of 5–7% and a revenue increase of 10–25% as ranges on its case page, but that engagement also covered the extension redesign, the MV3 migration and backend work, so none of it isolates the mobile app.
The counter-case: if the mobile use case is read-heavy and occasional, a responsive web app plus one narrow native capability (push, or a biometric-gated approval companion) is often the better trade. Apple's guideline 4.2 rejects apps that don't rise above a repackaged website anyway.
Sequencing it
If I were scoping this again tomorrow:
1. Pick a parity target and write it down in flows, not features.
2. Fix the read contract first: aggregate server-side, paginate everything, version deliberately, and plan to keep old versions alive for a year.
3. Decide the auth and custody model before the first screen. Biometrics unlock a local key; the server never trusts them.
4. Budget the platform plumbing (files, PDFs, camera, permissions) as features with owners.
5. Set up dev/staging/prod separation and OTA delivery before the first store submission.
6. Share tokens, components and schemas. Fork the logic and stop feeling bad about it.
The uncomfortable part is the last one. Once the app is live, the web product has a permanent second client that can't be hotfixed, running on binaries you shipped months ago, against an API you can no longer change on a whim. That constraint outlives the launch, and it's the real thing you signed up for in that scoping meeting.
Migrating from CodePush to react-native-update: a step-by-step guide
Disclosure: I maintain react-native-update, an open-source OTA SDK for React Native, and the hosted service behind it, Cresc. This guide is written for teams that have already decided to leave CodePush. If you're still deciding, I list the other options honestly at the end.
App Center was retired on March 31, 2025, and the react-native-code-push repository is now archived. If your app still calls codePush.sync(), those checks have nowhere to go: the app keeps running whatever bundle it has and stops getting updates.
This guide moves a React Native app from CodePush to react-native-update. Your release workflow stays the same: ship JS and asset changes without a store review, roll out gradually, and roll back if something breaks. On a typical RN 0.7x+ app the code changes take an afternoon.
1. How the concepts map
Most of the work is translating concepts, so here's the cheat sheet first:
| CodePush | react-native-update | Notes |
|---|---|---|
| App Center app (owner/MyApp-iOS) | App per platform (cresc createApp --platform ios) | Same idea: one app record per platform |
| Deployment key in Info.plist / strings.xml | appKey in update.json | update.json is generated by the CLI; commit it |
| Staging deployment | Test via QR code / deep link before binding | Scan a QR in the dashboard to load an unreleased version on a test device |
| Production deployment | Binding a version to native package(s) | Users get an update only once it's bound to their native version |
| --target-binary-version "~1.1.0" | --packageVersionRange "~1.1.0" | Both semver ranges |
| --rollout 25 | --rollout 25 | Same name. Bumping to 100 promotes to full |
| appcenter codepush release-react | cresc bundle --platform ios | Builds, publishes and (optionally) binds in one step |
| codePush(options)(App) | <UpdateProvider client={...}> | |
| checkFrequency: ON_APP_START / ON_APP_RESUME / MANUAL | checkStrategy: "onAppStart" / "onAppResume" / null | "both" (the default) does both |
| installMode: ON_NEXT_RESTART | updateStrategy: "silentAndLater" | |
| installMode: IMMEDIATE | updateStrategy: "silentAndNow" | |
| updateDialog: true | updateStrategy: "alwaysAlert" | |
| codePush.notifyAppReady() | markSuccess() (automatic by default) | Unmarked updates roll back on next launch |
| appcenter codepush rollback | Pause, then re-bind the previous version | Dashboard or cresc update |
The biggest difference: there are no deployments. A published version does nothing until you bind it to one or more native versions, optionally at a rollout percentage. You do your testing before binding, using the QR code.
2. Swap the packages
npm uninstall react-native-code-push
npm i react-native-update
npm i -g react-native-update-cli
cd ios && pod install
On RN 0.60+ autolinking handles the native module. Then delete the CodePush config:
- ios/<App>/Info.plist: remove CodePushDeploymentKey (and CodePushServerURL if you self-hosted)
- android/app/src/main/res/values/strings.xml: remove CodePushDeploymentKey
- android/app/build.gradle / settings.gradle: remove any manual codepush.gradle / :react-native-code-push lines if you added them for an old RN version
3. Point the native bundle URL at the new SDK
This is the same hook CodePush used, with a different provider.
iOS, Objective-C (AppDelegate.mm):
- #import <CodePush/CodePush.h>
+ #import "RCTPushy.h"
- (NSURL *)bundleURL
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
- return [CodePush bundleURL];
+ return [RCTPushy bundleURL];
#endif
}
On RN < 0.74 the method is sourceURLForBridge: instead of bundleURL; the change is the same.
RN 0.88+: the iOS template moved to the UIScene life cycle, so bundleURL() now lives in SceneDelegate.swift instead of AppDelegate. Make the change below there. If you upgraded to 0.88 with the Upgrade Helper and moved the entry point, carry RCTPushy.bundleURL() over too, or Release builds keep loading the embedded bundle and updates never apply.
iOS, Swift (AppDelegate.swift, or SceneDelegate.swift on RN 0.88+):
+ import react_native_update
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
- CodePush.bundleURL()
+ RCTPushy.bundleURL()
#endif
}
Android, Kotlin, RN ‚â§ 0.81 (MainApplication.kt):
- import com.microsoft.codepush.react.CodePush
+ import cn.reactnative.modules.update.UpdateContext
override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
- override fun getJSBundleFile(): String = CodePush.getJSBundleFile()
+ override fun getJSBundleFile(): String? =
+ UpdateContext.getBundleUrl(this@MainApplication)
// ...
}
Android, Kotlin, RN 0.82+ uses ReactHost, so pass the path as a parameter:
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages,
jsBundleFilePath = UpdateContext.getBundleUrl(this),
)
}
Two Android details that bite people:
- If you use react-native-screens (react-navigation does), register RNScreensFragmentFactory in MainActivity.onCreate. Without it you can get a blank screen after an OTA restart.
- Set crunchPngs false in the release build type. PNG crunching makes binary diffs unpredictable and the patches bigger.
Expo (SDK 50+, after npx expo prebuild): with Expo 48+ and react-native-update ‚â• 10.28.2 the bundle URL is configured automatically, so you can skip this section. Don't keep expo-updates installed alongside it, because the two conflict.
4. Replace the JS wrapper
Create the app records once. This writes update.json, which holds the appKey for each platform:
cresc login
cresc createApp --platform ios
cresc createApp --platform android
Then swap the wrapper. A typical CodePush setup:
import codePush from "react-native-code-push";
const codePushOptions = {
checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,
installMode: codePush.InstallMode.ON_NEXT_RESTART,
};
export default codePush(codePushOptions)(App);
becomes:
import { Platform } from "react-native";
import { Cresc, UpdateProvider } from "react-native-update";
import updateConfig from "./update.json";
const client = new Cresc({
appKey: updateConfig[Platform.OS].appKey,
checkStrategy: "onAppResume",
updateStrategy: "silentAndLater",
});
export default function Root() {
return (
<UpdateProvider client={client}>
<App />
</UpdateProvider>
);
}
If you had a custom update dialog built on codePush.sync() callbacks, set updateStrategy: null and drive your UI from the useUpdate() hook:
import { useUpdate } from "react-native-update";
function UpdateBanner() {
const { updateInfo, downloadUpdate, switchVersion, switchVersionLater, progress } =
useUpdate();
if (!updateInfo?.update) return null;
// updateInfo.name / description / metaInfo come from the published version.
// `if (await downloadUpdate()) await switchVersion()`: it resolves to false
// when the download didn't complete, so check it before offering a restart.
// progress.received / progress.total drive a progress bar.
// ...
}
Mandatory updates (CodePush's -m) don't have a dedicated flag. Either use updateStrategy: "silentAndNow", or publish with --metaInfo '{"mandatory":true}' and branch on JSON.parse(updateInfo.metaInfo) in your own UI (metaInfo arrives as a string). There's a worked example in the docs.
This covers the common options. For the rest (ON_NEXT_RESUME, disallowRestart, getUpdateMetadata, clearUpdates, restartApp and the App Center CLI commands), see the full CodePush API mapping.
Rollback safety: what replaces notifyAppReady()
CodePush rolled back an update if notifyAppReady() wasn't called after it launched. react-native-update works the same way through markSuccess(), but the provider calls it for you 1 second after mount (autoMarkSuccess: true by default). If the new bundle crashes before that, the next launch rolls back to the previous version and reports a rollback event.
If your app does critical work after the first frame (navigation restore, remote config), mark success later instead:
const client = new Cresc({
appKey,
autoMarkSuccessDelayMs: 5000,
// or return false to skip marking this launch:
healthCheck: async () => remoteConfigLoaded(),
});
5. Upload the native baseline
This step has no CodePush equivalent, and it's what makes the patches small. Updates are shipped as binary diffs against the exact build users have installed, so upload the same artifact you send to the stores:
cresc uploadIpa build/MyApp.ipa
cresc uploadApk android/app/build/outputs/apk/release/app-release.apk
# or, if you distribute an AAB:
cresc uploadAab android/app/build/outputs/bundle/release/app-release.aab
The native version (CFBundleShortVersionString / versionName) becomes the package version that your --packageVersionRange targets. Bump it on every native rebuild. Two different builds with the same version number still work, but you lose diff reuse and the patches get bigger.
6. Publish from CI
Create an API key in the dashboard (Write permission is enough), store it as CRESC_API_TOKEN, and run the CLI. It detects CI's missing TTY and runs non-interactively; --no-interactive makes that explicit. Passing a native range and a rollout publishes and binds in one step:
# .github/workflows/ota.yml
name: OTA release
on:
workflow_dispatch:
inputs:
rollout:
description: "Rollout percentage"
default: "10"
jobs:
publish:
runs-on: ubuntu-latest
strategy:
matrix:
platform: [ios, android]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- run: npm ci && npm i -g react-native-update-cli
- name: Bundle, publish and bind
env:
CRESC_API_TOKEN: ${{ secrets.CRESC_API_TOKEN }}
run: |
cresc bundle --platform ${{ matrix.platform }} \
--name "${GITHUB_SHA::7}" \
--description "OTA release ${GITHUB_SHA::7}" \
--packageVersionRange ">=2.0.0 <3.0.0" \
--rollout ${{ inputs.rollout }} \
--no-interactive
The CodePush equivalent was roughly appcenter codepush release-react -a owner/MyApp-iOS -d Production -t ">=2.0.0 <3.0.0" --rollout 10.
Two behaviors you get without extra configuration:
- On Hermes apps, the CLI compiles each release against an earlier bytecode of the same app and applies a Hermes-aware transform before diffing. In our benchmark a one-line fix ships as a 3.4 KB patch, where a plain bsdiff of the same change is 93.7 KB.
- The source map is archived with each version, so JS crashes from that release show up symbolicated in the dashboard.
7. Test, roll out, roll back
1. Before binding, enable "Use Deep Link" in the dashboard for your app's URL scheme and scan the version's QR code on a test device. That loads the exact unreleased bundle, which replaces what you used Staging for. (The Android activity needs launchMode="singleTask" and an intent filter for the scheme.)
2. Bind at a low percentage. Buckets are a stable hash of the device ID, so the same users stay in the canary as you widen it. Each native version can have one canary and one full release at the same time.
3. Watch the version's health in the dashboard: download failures, patch failures, rollbacks and symbolicated JS errors.
4. Promote by raising the rollout to 100, which replaces the previous full release.
5. If it's bad, pause the native package's updates and re-bind the previous version, or revert in git and publish a new version. Devices that crashed on launch have already rolled themselves back.
6. If JS is dead on some devices (white screen, crash before the update check runs), publish the fix with Force Boot. Since 10.52.1 the SDK runs a native cold-start check that doesn't depend on JS, so those devices can still pull the fix.
8. The transition period
- Users on old binaries keep their last CodePush bundle. Nothing breaks, but they won't get updates until they install the new store build. Prioritize getting the first react-native-update build out.
- You can't send an OTA to CodePush users. The migration needs one native release. Plan it like any store submission.
- If you self-hosted a CodePush server, you can keep it running for old binaries while new binaries use react-native-update. The two don't interact.
- Once nobody is on the old binaries, delete the App Center tokens from CI and remove the CodePush server.
Other options, honestly
- Self-host CodePush. Before App Center retired, Microsoft open-sourced a standalone CodePush server. If you'd rather run your own infrastructure and keep the existing SDK, this is the smallest code change. Note that the server and the client SDK are both archived now, so you take on hosting and maintaining both.
- Expo Updates (EAS Update). A great fit if you already use EAS for builds. It ships full bundles instead of diffs and bills by update MAU and bandwidth, which matters at scale.
- react-native-update / Cresc. Hermes-aware binary diffs, staged rollouts, automatic rollback and built-in crash symbolication. The SDK, CLI and admin dashboard are open source and there's a free tier.
Questions about a specific setup (brownfield, monorepo, custom bundle names) are welcome in the comments.
Mentions
How to Take a Fintech Product from Web to Mobile Every web-to-mobile project I've been on had the same scoping meeting. Someone opens with "we already have the API, so mobile is mostly UI," the room nods, and a quarter gets pencilled in. That sentence hides four things: the read contract your web app grew up with, the auth model that lives in a cookie, the OS plumbing a browser gave you for free, and a release cycle where "ship the fix" meant a redeploy, not a store review. Most of the work lands behind the screens. The mobile client is the cheap part; what costs money is everything the web app was allowed to assume. This is about re-negotiating those assumptions, with shipped projects as evidence. Decide what parity means before you scope "Parity" is the default nobody chooses. It arrives because nobody wrote down a narrower target, and it quietly becomes the acceptance criterion. There are three defensible targets, and you should pick one out loud: 1. Companion app. Read everything, do one core action (approve a payment, sign a transaction, check a balance). Cheapest, and often the right first release. 2. Functional parity. Every flow on web works on mobile. This is what most stakeholders mean when they say "the app." 3. Mobile-first rebuild. Mobile is a net-new surface with its own flows, and web follows later. Two projects show the ends of that spectrum. On Moneygate, a payment provider regulated by the Central Bank of Cyprus, the brief was explicitly a mobile app replicating all the functionality of the web platform: payments, multi-currency, fraud prevention, reporting, analytics. Parity was a stated requirement, and a five-person team delivered it: two React Native devs, a QA engineer, a designer and a business analyst. The BA mattered; the case page counts more than 22 business flows and features that had to be documented before they could be scoped. Ctrl, the wallet formerly known as XDEFI, went the other way. The product existed only as a web version and a browser extension, and "no mobile access" was the first problem on the list. Mobile there was new surface, not a port. One limitation to accept up front: parity is measured in flows, not features. A web screen with six optional filters and a CSV export is not one mobile screen; it's three, plus a decision about which filter combinations deserve a bottom sheet. Your web API is the first thing to break Web APIs evolve under assumptions mobile invalidates: - Many small round trips are fine, because the tab sits on Wi-Fi. - Large payloads are fine, because the page renders whatever arrives. - Offline is a non-case; the user reloads. - Old API versions die the moment you redeploy, because every client reloads on the next visit. Mobile invalidates all four, and the last one hurts most. The moment you ship a binary, you own a population of clients you cannot force-upgrade. In June 2025 Apple's own numbers put iOS 18 on 82% of all active iPhones, nine months after release: one in five devices still on an older OS. Android is worse; April 2026 Statcounter data compiled by apilevels shows Android 11 or newer on 86.9% of devices, with the newest release at 22.3%. Your app's update curve follows the same shape, so a /v1 endpoint you'd love to delete will get traffic for a year. Chatty endpoints, cold starts and the unified read The practical fix is server-side aggregation and deliberate versioning. Android vitals flags a cold start of 5 seconds or longer as excessive, and a home screen that fans out to eight REST calls on a cellular connection eats that budget before the first balance renders. DEFI Wallet, a non-custodial multichain wallet spanning 20 blockchains, is the clearest example of building the read contract once. Its backend is a set of microservices behind a GraphQL router that combines their schemas: blockchain indexers for real-time transactions, a Python service for token metadata, and Node.js/Nest.js Asset and NFT balance services. That single graph serves the browser extensions (Chrome, Edge, Opera, Brave), the web app and the iOS/Android apps. Mode, a UK-listed crypto company with GBP, EUR and BTC accounts, used microservices for the same reason across its mobile backend and admin portal, exposing GraphQL to both. What that looks like from the client side is one query per screen, not one per widget: One round trip for the portfolio screen; the router fans out server-side query PortfolioScreen($account: ID!, $fiat: Currency!) { account(id: $account) { balances(fiat: $fiat) { asset { symbol chain } amount fiatValue } nfts(first: 20) { edges { node { id name imageUrl } } } recentTransactions(first: 10) { hash status timestamp } } } The thing to look at is the first: arguments. Pagination limits that were optional on web become mandatory on mobile, because the alternative is a 4 MB response on a train. The limitation is real, though. A unified API is a coupling decision: one aggregation layer serving three clients becomes the bottleneck every team queues behind, and a schema change for the extension now has a mobile review attached to it. In the 2024 State of React Native survey, 59.3% of respondents use TanStack Query and 34.2% use Apollo Client. Whichever you pick, the server contract decides how many requests a screen makes, not the client library. State management doesn't survive the trip Redux-shaped web state is tuned for a tab that reloads on demand. A mobile app backgrounds, loses the network in a lift, and resumes four hours later with a store full of stale balances and an expired token. The state model has to separate two things the web app blurred: - Server state: cache, refetch, invalidation, background sync. Belongs to a query library. - Client state: navigation, form input, UI toggles. Belongs to a small store, persisted deliberately. Moneygate used React Query for server state under a server-state pattern, with React Hook Form for input. Ctrl runs Redux Toolkit with Redux Persist in the extension but Zustand in the React Native app. DEFI Wallet made the same split: Redux on web, Zustand on mobile. That is three teams independently accepting two state libraries in one product. They're not alone: Redux is still used by 71% of React Native respondents and Zustand by 46.7%, with Redux complexity the third most-cited pain point. Two stores is a maintenance tax; the case evidence shows teams paying it, not that it's free. The part that bit us was hydration ordering. A persisted store that rehydrates after the first render shows a logged-out screen for one frame, then flips, and on a payments app that flash is a support ticket. Gate rendering on hydration explicitly: // Zustand + persist: don't render auth-dependent screens until the store is back export const useSession = create<SessionState>()( persist( (set) => ({ token: null, hydrated: false, setToken: (t) => set({ token: t }) }), { name: 'session', storage: createJSONStorage(() => secureStorage), // not AsyncStorage for tokens onRehydrateStorage: () => (state) => state && (state.hydrated = true), }, ), ); The comment on storage is the point. AsyncStorage is used by 83.8% of respondents in the same survey, and it is plaintext. Session material goes in the keychain or keystore; everything else can go in AsyncStorage or MMKV. Platform plumbing is the backlog nobody writes down A browser handed the web app downloads, PDF viewing, printing and camera access, and none of it appeared in a ticket. On mobile each is an integration with its own permission model and OS-version behaviour. Budget them as features, not tasks. Files, PDFs and the Android storage rules Moneygate's challenge list included secure file downloads on Android and PDF preview with actions. Neither is exotic on web. On Android, apps targeting Android 11 or later run under scoped storage; the system ignores the requestLegacyExternalStorage flag, WRITE_EXTERNAL_STORAGE grants nothing extra, the Storage Access Framework refuses ACTION_OPEN_DOCUMENT_TREE on the Download directory, and declaring All Files Access "can affect your ability to publish and update your app on Google Play." So the web app's <a download> became, on Moneygate: - expo-file-system with the Storage Access Framework for user-directed saves. - rn-pdf-reader-js for in-app PDF preview with actions. - react-native-gesture-handler plus bottom-sheet for full-screen modals replacing traditional buttons. - Reanimated for animation performance, because a statement list that stutters reads as a broken app. Soapbox, a fulfilment-tech company's Android warehouse app, hit the other classic: React Native library compatibility problems implementing barcode scanning, settled with vision-camera-code-scanner and react-native-print. Every one of those libraries carries native code, narrows your upgrade path and becomes a dependency you own through every SDK bump. The 2024 survey put New Architecture adoption at almost 50% with library compatibility the standing concern; the PDF viewer you picked in year one is exactly what blocks you in year two. Biometrics are an unlock, not an authentication This is the argument I have most often with product owners. Face ID does not log a user in. It gates something on the device, and the server should never learn or care that a biometric happened. Treat it as a local factor: 1. The server issues a session or refresh token after real authentication (password, passkey, OTP, whatever your risk model requires). 2. The token sits in the keychain or keystore, wrapped by a key that requires user presence. 3. Biometrics unlock that key. Server-side authorization is unchanged and still expires and revokes on its own schedule. Android's docs separate BIOMETRIC_STRONG (Class 3) from BIOMETRIC_WEAK (Class 2) and provide CryptoObject so a successful prompt unlocks a Cipher or Signature rather than returning a boolean. OWASP lists cryptographic binding as a best practice for exactly that reason: a boolean callback can be bypassed or replayed; a keystore key with setUserAuthenticationRequired(true) cannot be used without the prompt succeeding. Moneygate used expo-local-authentication for biometric authorization. That library gives you the prompt; the key-wrapping around it is your design. Users want this. In the FIDO Alliance's 2024 barometer, biometrics ranked as the login method consumers consider both the best experience and the most secure for the second year running, and 42% had abandoned a purchase in the previous month over a forgotten password. That demand is exactly why teams are tempted to let the prompt stand in for auth. Custody and secrets change shape on mobile Custodial fintech has the easy version: the secret is a session token, and the worst case is re-login. Non-custodial products have the hard version. The key material lives on a device you don't control, next to a screenshot tool, a clipboard and whatever the user side-loaded. Ctrl and DEFI Wallet are both non-custodial. DEFI Wallet's case page states it does not store users' seed phrases or passwords, and it ships the same key operations across extensions, web and mobile, with Ledger hardware-wallet support as an alternative to on-device keys. What I can offer as engineering practice, kept separate from what those clients published: - Generate and use keys inside the platform's hardware-backed store; export only for recovery flows, never for convenience. - Bind signing to Class 3 biometrics or a device credential, with explicit confirmation per transaction, not per session. - Invalidate biometric-bound keys when enrolment changes, another OWASP best practice for the same reason. - Design recovery as a first-class flow. A wallet whose recovery path is "the user wrote the phrase down" has outsourced its most dangerous UX to a napkin. No source describes Ctrl's or DEFI Wallet's key storage, recovery or attestation design, so don't read that list as their architecture. It's the checklist the shipped surface implies you'll need. Reuse what's shared, fork what isn't The shared-code fantasy: one monorepo, one business-logic package, two thin UI shells. In practice the reuse band is narrower: - Design tokens. Colours, spacing, typography. Cheap to share, high payoff. - Components. Worth sharing when the primitive is the same on both platforms; the layout around it usually isn't. - Validation schemas. Zod or equivalent; IBAN rules don't change with screen size. - Business logic. Partial at best. The moment a flow touches storage, auth or navigation, it diverges. Ctrl's problem list named component inconsistency across platforms as something slowing development, and the answer was a unified component library for web and mobile, with the extension on Tamagui and Zod. DEFI Wallet listed accelerating web development through component reusability as an explicit challenge. Both teams treated the component layer as the reuse target. Neither claims they shared the business logic. The cost is coupling. A shared library forces one release cadence on two platforms with different constraints: the extension can ship a fix the same day; the app cannot. That's the price of consistency, and it's worth paying only if you also set up the release side. Shipping to a store is not shipping to a URL This is the shift teams underrate. Store review, staged rollout and users on months-old binaries change how you branch, feature-flag and hotfix. The numbers are better than they were, but they're not a redeploy: - Apple says 90% of submissions are reviewed in under 24 hours. The other 10% is where your Friday incident lives. - Google Play's staged rollouts let you halt a release, and the percentage never increases on its own; you go up, you don't go down. - Apple's guideline 2.5.2 says apps may not download or execute code that introduces or changes features. OTA JavaScript updates live inside the interpreted-code exception and the platforms' content rules; Expo's own docs are blunt that EAS Update handles non-native pieces such as JS, styling and images and anything native, any permission change, any SDK bump needs a new build. Soapbox is what it looks like when none of this is set up. The app arrived with no cloud-based JS update system, no automated code review or deployment, no separation between development, staging and production, frequent push conflicts, and manual Play Store updates required from users. The fix was environment stratification via GitLab and CodePush, ESLint, Datadog and full-cycle testing, followed by more than 15 successful releases. Fifteen is a count, not an improvement measure, but it's fifteen more than a team with no pipeline ships. Mode's case page lists 23. Two more things that shape release engineering, both easy to miss at scoping: 1. Your OTA vendor is a dependency. Microsoft retired App Center and CodePush on March 31, 2025 and published a standalone server to self-host. Adopt CodePush in 2023, inherit a migration in 2025. In the 2025 State of React Native survey, EAS Build is at 68.2% and GitHub Actions at 61.9%; the ecosystem consolidated, and that's a bet on someone else's roadmap. 2. The web side doesn't escape platform-mandated rewrites. Ctrl's extension had to migrate from Manifest V2 to V3. Chrome disabled MV2 for all users on all channels by March 2025, removed the re-enable switch with Chrome 138, and deletes the remaining MV2 extensions from the Web Store on August 31, 2026. An extension is a store-distributed client with its own review; teams that file it under "web" get surprised. Set up environments and OTA before the first store submission, not after the first incident. After the first incident you'll do it anyway, under worse conditions. What the move actually costs Stated plainly, going web-to-mobile buys you: - Two more runtimes with their own crash signatures. - Two more release channels, each with review and rollout mechanics. - A device and OS-version test matrix that grows every September. - Permanent version skew, so every API change is a compatibility decision. - A native-dependency surface (files, PDF, camera, biometrics) you upgrade by hand. None of that is a reason not to do it. Moneygate's five-person team and Ctrl's twelve-person dev and QA team show the headcount range between parity and net-new surface. Ctrl reports a user base expansion of 5–7% and a revenue increase of 10–25% as ranges on its case page, but that engagement also covered the extension redesign, the MV3 migration and backend work, so none of it isolates the mobile app. The counter-case: if the mobile use case is read-heavy and occasional, a responsive web app plus one narrow native capability (push, or a biometric-gated approval companion) is often the better trade. Apple's guideline 4.2 rejects apps that don't rise above a repackaged website anyway. Sequencing it If I were scoping this again tomorrow: 1. Pick a parity target and write it down in flows, not features. 2. Fix the read contract first: aggregate server-side, paginate everything, version deliberately, and plan to keep old versions alive for a year. 3. Decide the auth and custody model before the first screen. Biometrics unlock a local key; the server never trusts them. 4. Budget the platform plumbing (files, PDFs, camera, permissions) as features with owners. 5. Set up dev/staging/prod separation and OTA delivery before the first store submission. 6. Share tokens, components and schemas. Fork the logic and stop feeling bad about it. The uncomfortable part is the last one. Once the app is live, the web product has a permanent second client that can't be hotfixed, running on binaries you shipped months ago, against an API you can no longer change on a whim. That constraint outlives the launch, and it's the real thing you signed up for in that scoping meeting.
Migrating from CodePush to react-native-update: a step-by-step guide Disclosure: I maintain react-native-update, an open-source OTA SDK for React Native, and the hosted service behind it, Cresc. This guide is written for teams that have already decided to leave CodePush. If you're still deciding, I list the other options honestly at the end. App Center was retired on March 31, 2025, and the react-native-code-push repository is now archived. If your app still calls codePush.sync(), those checks have nowhere to go: the app keeps running whatever bundle it has and stops getting updates. This guide moves a React Native app from CodePush to react-native-update. Your release workflow stays the same: ship JS and asset changes without a store review, roll out gradually, and roll back if something breaks. On a typical RN 0.7x+ app the code changes take an afternoon. 1. How the concepts map Most of the work is translating concepts, so here's the cheat sheet first: | CodePush | react-native-update | Notes | |---|---|---| | App Center app (owner/MyApp-iOS) | App per platform (cresc createApp --platform ios) | Same idea: one app record per platform | | Deployment key in Info.plist / strings.xml | appKey in update.json | update.json is generated by the CLI; commit it | | Staging deployment | Test via QR code / deep link before binding | Scan a QR in the dashboard to load an unreleased version on a test device | | Production deployment | Binding a version to native package(s) | Users get an update only once it's bound to their native version | | --target-binary-version "~1.1.0" | --packageVersionRange "~1.1.0" | Both semver ranges | | --rollout 25 | --rollout 25 | Same name. Bumping to 100 promotes to full | | appcenter codepush release-react | cresc bundle --platform ios | Builds, publishes and (optionally) binds in one step | | codePush(options)(App) | <UpdateProvider client={...}> | | | checkFrequency: ON_APP_START / ON_APP_RESUME / MANUAL | checkStrategy: "onAppStart" / "onAppResume" / null | "both" (the default) does both | | installMode: ON_NEXT_RESTART | updateStrategy: "silentAndLater" | | | installMode: IMMEDIATE | updateStrategy: "silentAndNow" | | | updateDialog: true | updateStrategy: "alwaysAlert" | | | codePush.notifyAppReady() | markSuccess() (automatic by default) | Unmarked updates roll back on next launch | | appcenter codepush rollback | Pause, then re-bind the previous version | Dashboard or cresc update | The biggest difference: there are no deployments. A published version does nothing until you bind it to one or more native versions, optionally at a rollout percentage. You do your testing before binding, using the QR code. 2. Swap the packages npm uninstall react-native-code-push npm i react-native-update npm i -g react-native-update-cli cd ios && pod install On RN 0.60+ autolinking handles the native module. Then delete the CodePush config: - ios/<App>/Info.plist: remove CodePushDeploymentKey (and CodePushServerURL if you self-hosted) - android/app/src/main/res/values/strings.xml: remove CodePushDeploymentKey - android/app/build.gradle / settings.gradle: remove any manual codepush.gradle / :react-native-code-push lines if you added them for an old RN version 3. Point the native bundle URL at the new SDK This is the same hook CodePush used, with a different provider. iOS, Objective-C (AppDelegate.mm): - #import <CodePush/CodePush.h> + #import "RCTPushy.h" - (NSURL *)bundleURL { #if DEBUG return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; #else - return [CodePush bundleURL]; + return [RCTPushy bundleURL]; #endif } On RN < 0.74 the method is sourceURLForBridge: instead of bundleURL; the change is the same. RN 0.88+: the iOS template moved to the UIScene life cycle, so bundleURL() now lives in SceneDelegate.swift instead of AppDelegate. Make the change below there. If you upgraded to 0.88 with the Upgrade Helper and moved the entry point, carry RCTPushy.bundleURL() over too, or Release builds keep loading the embedded bundle and updates never apply. iOS, Swift (AppDelegate.swift, or SceneDelegate.swift on RN 0.88+): + import react_native_update override func bundleURL() -> URL? { #if DEBUG RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") #else - CodePush.bundleURL() + RCTPushy.bundleURL() #endif } Android, Kotlin, RN ‚â§ 0.81 (MainApplication.kt): - import com.microsoft.codepush.react.CodePush + import cn.reactnative.modules.update.UpdateContext override val reactNativeHost: ReactNativeHost = object : DefaultReactNativeHost(this) { - override fun getJSBundleFile(): String = CodePush.getJSBundleFile() + override fun getJSBundleFile(): String? = + UpdateContext.getBundleUrl(this@MainApplication) // ... } Android, Kotlin, RN 0.82+ uses ReactHost, so pass the path as a parameter: override val reactHost: ReactHost by lazy { getDefaultReactHost( context = applicationContext, packageList = PackageList(this).packages, jsBundleFilePath = UpdateContext.getBundleUrl(this), ) } Two Android details that bite people: - If you use react-native-screens (react-navigation does), register RNScreensFragmentFactory in MainActivity.onCreate. Without it you can get a blank screen after an OTA restart. - Set crunchPngs false in the release build type. PNG crunching makes binary diffs unpredictable and the patches bigger. Expo (SDK 50+, after npx expo prebuild): with Expo 48+ and react-native-update ‚â• 10.28.2 the bundle URL is configured automatically, so you can skip this section. Don't keep expo-updates installed alongside it, because the two conflict. 4. Replace the JS wrapper Create the app records once. This writes update.json, which holds the appKey for each platform: cresc login cresc createApp --platform ios cresc createApp --platform android Then swap the wrapper. A typical CodePush setup: import codePush from "react-native-code-push"; const codePushOptions = { checkFrequency: codePush.CheckFrequency.ON_APP_RESUME, installMode: codePush.InstallMode.ON_NEXT_RESTART, }; export default codePush(codePushOptions)(App); becomes: import { Platform } from "react-native"; import { Cresc, UpdateProvider } from "react-native-update"; import updateConfig from "./update.json"; const client = new Cresc({ appKey: updateConfig[Platform.OS].appKey, checkStrategy: "onAppResume", updateStrategy: "silentAndLater", }); export default function Root() { return ( <UpdateProvider client={client}> <App /> </UpdateProvider> ); } If you had a custom update dialog built on codePush.sync() callbacks, set updateStrategy: null and drive your UI from the useUpdate() hook: import { useUpdate } from "react-native-update"; function UpdateBanner() { const { updateInfo, downloadUpdate, switchVersion, switchVersionLater, progress } = useUpdate(); if (!updateInfo?.update) return null; // updateInfo.name / description / metaInfo come from the published version. // `if (await downloadUpdate()) await switchVersion()`: it resolves to false // when the download didn't complete, so check it before offering a restart. // progress.received / progress.total drive a progress bar. // ... } Mandatory updates (CodePush's -m) don't have a dedicated flag. Either use updateStrategy: "silentAndNow", or publish with --metaInfo '{"mandatory":true}' and branch on JSON.parse(updateInfo.metaInfo) in your own UI (metaInfo arrives as a string). There's a worked example in the docs. This covers the common options. For the rest (ON_NEXT_RESUME, disallowRestart, getUpdateMetadata, clearUpdates, restartApp and the App Center CLI commands), see the full CodePush API mapping. Rollback safety: what replaces notifyAppReady() CodePush rolled back an update if notifyAppReady() wasn't called after it launched. react-native-update works the same way through markSuccess(), but the provider calls it for you 1 second after mount (autoMarkSuccess: true by default). If the new bundle crashes before that, the next launch rolls back to the previous version and reports a rollback event. If your app does critical work after the first frame (navigation restore, remote config), mark success later instead: const client = new Cresc({ appKey, autoMarkSuccessDelayMs: 5000, // or return false to skip marking this launch: healthCheck: async () => remoteConfigLoaded(), }); 5. Upload the native baseline This step has no CodePush equivalent, and it's what makes the patches small. Updates are shipped as binary diffs against the exact build users have installed, so upload the same artifact you send to the stores: cresc uploadIpa build/MyApp.ipa cresc uploadApk android/app/build/outputs/apk/release/app-release.apk # or, if you distribute an AAB: cresc uploadAab android/app/build/outputs/bundle/release/app-release.aab The native version (CFBundleShortVersionString / versionName) becomes the package version that your --packageVersionRange targets. Bump it on every native rebuild. Two different builds with the same version number still work, but you lose diff reuse and the patches get bigger. 6. Publish from CI Create an API key in the dashboard (Write permission is enough), store it as CRESC_API_TOKEN, and run the CLI. It detects CI's missing TTY and runs non-interactively; --no-interactive makes that explicit. Passing a native range and a rollout publishes and binds in one step: # .github/workflows/ota.yml name: OTA release on: workflow_dispatch: inputs: rollout: description: "Rollout percentage" default: "10" jobs: publish: runs-on: ubuntu-latest strategy: matrix: platform: [ios, android] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 22 - run: npm ci && npm i -g react-native-update-cli - name: Bundle, publish and bind env: CRESC_API_TOKEN: ${{ secrets.CRESC_API_TOKEN }} run: | cresc bundle --platform ${{ matrix.platform }} \ --name "${GITHUB_SHA::7}" \ --description "OTA release ${GITHUB_SHA::7}" \ --packageVersionRange ">=2.0.0 <3.0.0" \ --rollout ${{ inputs.rollout }} \ --no-interactive The CodePush equivalent was roughly appcenter codepush release-react -a owner/MyApp-iOS -d Production -t ">=2.0.0 <3.0.0" --rollout 10. Two behaviors you get without extra configuration: - On Hermes apps, the CLI compiles each release against an earlier bytecode of the same app and applies a Hermes-aware transform before diffing. In our benchmark a one-line fix ships as a 3.4 KB patch, where a plain bsdiff of the same change is 93.7 KB. - The source map is archived with each version, so JS crashes from that release show up symbolicated in the dashboard. 7. Test, roll out, roll back 1. Before binding, enable "Use Deep Link" in the dashboard for your app's URL scheme and scan the version's QR code on a test device. That loads the exact unreleased bundle, which replaces what you used Staging for. (The Android activity needs launchMode="singleTask" and an intent filter for the scheme.) 2. Bind at a low percentage. Buckets are a stable hash of the device ID, so the same users stay in the canary as you widen it. Each native version can have one canary and one full release at the same time. 3. Watch the version's health in the dashboard: download failures, patch failures, rollbacks and symbolicated JS errors. 4. Promote by raising the rollout to 100, which replaces the previous full release. 5. If it's bad, pause the native package's updates and re-bind the previous version, or revert in git and publish a new version. Devices that crashed on launch have already rolled themselves back. 6. If JS is dead on some devices (white screen, crash before the update check runs), publish the fix with Force Boot. Since 10.52.1 the SDK runs a native cold-start check that doesn't depend on JS, so those devices can still pull the fix. 8. The transition period - Users on old binaries keep their last CodePush bundle. Nothing breaks, but they won't get updates until they install the new store build. Prioritize getting the first react-native-update build out. - You can't send an OTA to CodePush users. The migration needs one native release. Plan it like any store submission. - If you self-hosted a CodePush server, you can keep it running for old binaries while new binaries use react-native-update. The two don't interact. - Once nobody is on the old binaries, delete the App Center tokens from CI and remove the CodePush server. Other options, honestly - Self-host CodePush. Before App Center retired, Microsoft open-sourced a standalone CodePush server. If you'd rather run your own infrastructure and keep the existing SDK, this is the smallest code change. Note that the server and the client SDK are both archived now, so you take on hosting and maintaining both. - Expo Updates (EAS Update). A great fit if you already use EAS for builds. It ships full bundles instead of diffs and bills by update MAU and bandwidth, which matters at scale. - react-native-update / Cresc. Hermes-aware binary diffs, staged rollouts, automatic rollback and built-in crash symbolication. The SDK, CLI and admin dashboard are open source and there's a free tier. Questions about a specific setup (brownfield, monorepo, custom bundle names) are welcome in the comments.