Joshua Damon
React NativeJune 22, 202610 min read

Surviving a Major React Native Framework Upgrade

Upgrading the framework, UI library, and JS runtime on a mature app with a large native-module surface — without freezing releases. How to sequence, stage, and validate a foundational bump module-by-module.

React NativeExpoUpgradesNative ModulesReleases

Every mature React Native app eventually faces the same uncomfortable question: it has drifted several versions behind on its framework, UI library, and JS runtime, and the gap is widening. Upgrading is a large, risky project with no new features to show for it. Not upgrading slowly strangles the app — dependencies stop supporting old versions, security patches dry up, and new hires recoil from the stack.

I've led one of these on a long-lived production app — a major version jump of the framework, the UI library, and the JS runtime at once, across a large native-module surface, while the team kept shipping. Here's what made it survivable.

Why it's hard, specifically

It's tempting to think of an upgrade as "bump the version numbers and fix what breaks." On a mature app, the difficulty is structural:

  • The native surface is large. Media, notifications, storage, animation, gestures — each is a native module with its own compatibility matrix. One framework bump ripples through every one of them, and a single incompatible module can block the whole upgrade.
  • Breaking changes are everywhere and subtle. A major jump brings API removals, changed defaults, and behavior shifts that compile fine and then misbehave at runtime.
  • Releases can't stop. The business doesn't pause for your refactor. A bugfix or a deadline-driven feature will need to ship while the upgrade is mid-flight, and your branch strategy has to allow it.

Treat it as a version bump and it becomes a multi-week branch that rots, conflicts endlessly with main, and lands as one terrifying all-or-nothing merge. The job is to make it not that.

Sequence behind a long-lived branch — deliberately

The conventional wisdom is "avoid long-lived branches." For a foundational upgrade, a long-lived branch is exactly the right tool — if you manage it deliberately.

The upgrade lives on its own feature branch so day-to-day releases keep flowing from main untouched. You accept that the branch will live a while, and in exchange you protect the release train completely. The discipline is keeping that branch honestly in sync: merge main into it frequently so conflicts stay small and continuous, rather than discovering at the end that three weeks of feature work now collides with your dependency changes.

The trade is explicit: a longer-lived branch in exchange for zero release disruption. On a production app with real users, that's almost always the right side of the trade.

Validate module-by-module, not all-at-once

The failure mode of a big upgrade is flipping every version at once, hitting a wall of red, and having no idea which of forty changes caused which of eighty failures.

The antidote is to make the upgrade legible. Align the dependency surface — on the order of eighty native and JS dependencies, plus the toolchain — in deliberate groups, and validate as you go. Get the build green, then exercise the highest-risk subsystems one at a time: does background media still work? Do notifications still deep-link? Does the gesture-driven navigation still feel right? Each native module gets confirmed against the new framework before you move on, so when something breaks you know exactly what introduced it.

This is slower than a blind bump and dramatically faster than debugging a fully-broken app, because you're never more than one change away from the last known-good state.

Ship through staged channels

The riskiest moment of an upgrade isn't the merge — it's the first contact with real devices, real OS versions, and real network conditions you didn't test. A foundational change can pass every check in CI and still surface a regression that only appears on a specific Android version in the field.

Staged distribution channels are how you de-risk that. The upgraded build goes out through a development and preview channel first, then to a fraction of production before widening. You watch crash-free rate and the key journeys before committing the whole user base. A regression caught at a small percentage is an incident; the same regression at 100% is a crisis.

The native boundary is the real work

If there's one thing I'd tell anyone facing this: the framework version in package.json is the easy part. The work is the native modules around it.

Every native dependency you carry is an ongoing tax — something you now have to keep building, keep compatible across architecture versions, and keep alive through OS updates. A major upgrade is when that tax comes due all at once. It's also the right moment to ask, of each native module, whether it still earns its place. Some you upgrade. Some you replace with a maintained alternative. Some you discover you can drop entirely. The upgrade isn't just catching up; it's pruning.

What "done" looks like

The outcome you want isn't a changelog. It's an app on a modern, supported baseline — easier to hire for, easier to patch, ready for the next round of features — delivered without a single disrupted release along the way. No drama, no emergency rollback, no week of firefighting after the merge.

Large upgrades feel impossible because people imagine them as a single leap. They're survivable when you treat them as what they actually are: a sequenced, staged, module-by-module migration, run quietly on the side while the product keeps shipping.