Upgrading React Native from 0.63 to 0.73 in a production app
A year-long incremental upgrade across five major versions in Status, a ClojureScript app with Go bridges, nix builds, and a full Java-to-Kotlin migration along the way.
When I joined the React Native upgrade effort at Status, the app was pinned to React Native 0.63.3. By the time the dust settled, we were on 0.73.5 with the new architecture enabled on iOS. It took about a year, touched over 200 files, and required rewriting the entire Android native layer from Java to Kotlin along the way.
This is not a guide. It's a war story.

Why incremental#
The conventional wisdom is to upgrade React Native in one shot: run the upgrade helper, fix what breaks, merge. That works when your app is a standard RN project with a handful of native modules. Status is not that. It's a ClojureScript app compiled to React Native, with a Go backend bridged through native modules, custom C libraries, a nix-based build system, and CI pipelines that build for Android and iOS on every commit.
A single jump from 0.63 to 0.73 would have been a months-long branch that diverged from develop until it was unmergeable. Instead, I treated each minor version as a stepping stone: get it building, get it through QA, merge to develop, let it soak, then start the next jump.
0.63.3 to 0.67.5: the first wall#
The first upgrade (#15486) was the hardest psychologically because nothing worked and I didn't yet know the patterns. React Native 0.67 changed how the Android build resolved soloader and libhermes, which broke our Gradle configuration in ways that produced cryptic errors deep in the native build.
The iOS side had its own issues. The auto-complete prop on text inputs changed behavior, and our custom fast-image integration for link previews needed to be reworked. On Android, the shadow/elevation rendering changed, which broke the audio record button layout, a bug that only showed up on specific screen densities.
31 files changed, 47 review comments, and about three weeks of calendar time. But once it merged, we had a pattern.
I also had to patch the RN build scripts to stop looking for nvm. On systems with nvm installed, the 0.67.x build scripts would grab the node version from nvm instead of the nix-pinned version, which silently broke builds.
0.67.5 to 0.69.10: platform-first testing#
For this jump I changed the approach. Instead of one combined PR, I opened separate iOS-only (#15645) and Android-only (#15995) PRs first. Each one ran through CI independently, which let me isolate platform-specific failures without the noise of the other platform's errors.
The combined PR (#16016) landed with 41 files changed and nearly 10,000 lines added, most of it in lock files and autogenerated Gradle configuration. The important thing was that 0.69 put us on an officially supported version. Everything before 0.69 was already end-of-life by the time we started.
0.69.10 to 0.72.5: the big one#
This was the most complex upgrade (#17241). It wasn't just React Native, it was everything around it. We bumped react-native-reanimated to 3.5.4, react-native-navigation to 7.37.0, the Android NDK to 25.2, Kotlin to 1.7.22, AGP to 7.4.2, Gradle to 8.0.1, and moved target SDK to 33.
I made a deliberate choice here: stay on JavaScriptCore for iOS and Hermes for Android. Enabling the new architecture would come later. Trying to do everything at once is how upgrade efforts die.

The PR collected 132 review comments over three months. Some of those comments were about the upgrade itself. Many were about the side effects: the metro bundler start time regressed because the make run-metro target had to change from clojure to android, and several team members reported slower iteration cycles.
We also used this upgrade as an opportunity to remove @walletconnect/client entirely. It was deprecated and we had a replacement ready. Removing dead dependencies during an upgrade is one of the few times you can do it without anyone objecting.
I also had to add Xcode 15 support separately, fixing a std::unary_function error in boost headers caused by Clang changes in the new Xcode.
The Kotlin migration#

React Native 0.73 required Kotlin for MainActivity and MainApplication. But our codebase had dozens of Java files in the native layer, and converting just the entry points would have left us in an awkward half-Java, half-Kotlin state.
I opened a tracking issue with a checklist of every Java file and started converting them systematically. The first PR converted the two entry points. Then I refactored the monolithic StatusModule.java into separate modules: AccountManager, EncryptionUtils, DatabaseManager, UIHelper, LogManager, NetworkManager, on both platforms. This wasn't just a language migration; it was a chance to clean up years of accumulated native code.
The last Kotlin conversion PRs (#21877, #21878, #21879, #21881) merged in January 2025, closing out an issue that had been open for over a year.
0.72.5 to 0.73.5: the final push#
By this point I knew the playbook. The pre-requisites were the hard part: bumping react-native-webview to fix an iOS build failure, patching boost pod specs to use sourceforge after jfrog checksums broke, fixing xcbeautify swallowing build logs, and writing a custom iOS device deployment script because react-native cli and ios-deploy stopped working with Xcode 15 and iOS 17.
I also had to upgrade clang and patch glog to fix iOS builds on macOS Sonoma. The nix build system was passing conflicting CC/CXX flags to glog's configure script.
The upgrade PR itself (#18563) was 38 files, merging after 76 review comments and two months of QA. We replaced @react-native-community/clipboard with @react-native-clipboard/clipboard, bumped gesture-handler, navigation, share, and forced Java 17 everywhere.
E2E tests passed at 79%. Not perfect, but the failures were all pre-existing flaky tests, not regressions.
Enabling the new architecture#
With 0.73 stable, I turned on Fabric and Hermes on iOS (#19748). This was surprisingly smooth, one PR, merged in ten days. The key was that we'd already done the hard work of modularizing native code and eliminating forks.
Android was a different story. I opened a PR to enable the new architecture on Android for release builds only, using unstable_reactLegacyComponentNames to bridge components that weren't Fabric-ready. QA found five issues including precision loss errors, double-tap bugs, and broken bottom sheets. That PR was never merged. Sometimes the right decision is to stop.
The fork elimination campaign#
One of the most impactful side projects during the upgrade was eliminating forked dependencies. We had forked react-native-camera-roll, react-native-blur, react-native-mail, and several others. Each fork was a maintenance burden and a blocker for upgrades.
I introduced a patch mechanism: instead of maintaining forks, we keep .patch files in a patches/ directory and apply them at build time. I even added a make patch-file command for interactive patch generation. One by one, we swapped camera-roll, moved blur to a patch, swapped react-native-mail, and then nuked react-native-mail entirely by moving the logic into our own native modules.
For react-native-blur, I found and fixed a bug upstream, a random SHA mismatch during builds, and got it merged into the library itself. That's the ideal outcome: fix it upstream so nobody has to carry the patch.
What I'd tell someone starting this#
Don't try to jump more than two minor versions at once. The breaking changes compound, and your ability to bisect failures disappears when everything changed at the same time.
Open platform-specific sub-PRs for CI testing before combining into a single merge. It costs almost nothing and saves hours of debugging.
Use the upgrade as an opportunity to delete things. Every dependency you remove is one fewer thing that can break on the next upgrade.
And most importantly: merge to develop early and often. A long-lived upgrade branch is a branch that never ships.

Support · Optional and appreciated
If something I've built or written saved you time, you can keep the lights on.