Mobile App Development

Finishing the React Native New Architecture migration ahead of the deprecation deadline

APP

React Native's New Architecture has been the recommended default for a while now, but a couple of our older client apps had put off the migration, partly due to competing priorities and partly because the apps in question were stable enough under the old architecture that migrating felt like effort without an obvious near-term payoff. With the legacy architecture's deprecation deadline approaching this year and official support for it being fully removed, we finally finished the remaining migrations rather than continuing to defer them.

The apps that had already modernized their native module usage over the years migrated with minimal friction. Those apps had, mostly incidentally, kept their native module dependencies reasonably current and avoided some of the deepest legacy patterns that make a New Architecture migration painful, direct assumptions about the old bridge's synchronous behavior, custom native modules written years ago against APIs that had since evolved. For those apps, the migration was close to mechanical: update dependencies, run the provided migration tooling, fix a small number of flagged incompatibilities, and verify behavior against the existing test suite. For most apps the mechanical part of the switch came down to a couple of flags:

# android/gradle.properties
newArchEnabled=true
hermesEnabled=true

Running the upgrade helper against one of the already-modernized apps produced a short, unremarkable list of flagged incompatibilities, which is roughly the experience we'd hoped for across the board:

$ npx react-native upgrade-helper --check-new-arch
Scanning 34 native modules...

  ✓ 31 modules report New Architecture support
  ⚠ 3 modules flagged for review:
    - react-native-legacy-camera-view (no TurboModule spec)
    - react-native-old-blur (uses deprecated bridge.callNativeSyncHook)
    - internal-analytics-bridge (custom, unmaintained since 2022)

Run with --verbose for per-module migration notes.

Two of those three turned out to have actively maintained forks with New Architecture support already merged upstream; the third, `internal-analytics-bridge`, was the one we ended up forking ourselves.

The one app still carrying older, less-maintained native dependencies took real extra effort, a reminder that deferred migrations tend to get more expensive, not less, the longer they wait. Several of that app's native modules hadn't been actively maintained by their original authors in years, meaning there was no upstream New Architecture-compatible version to simply upgrade to. We ended up forking two smaller native modules ourselves to port them forward, which is exactly the kind of unplanned maintenance burden that deferred technical debt tends to eventually generate, and which cost meaningfully more engineering time than if the app had migrated a year or two earlier alongside its peers.

Porting that module forward meant replacing its synchronous bridge call with a proper TurboModule spec the new architecture's codegen can consume:

- RCT_EXPORT_SYNCHRONOUS_METHOD(getDeviceId)
- {
-   return [[UIDevice currentDevice] identifierForVendor].UUIDString;
- }
+ - (void)getDeviceId:(RCTPromiseResolveBlock)resolve
+            rejecter:(RCTPromiseRejectBlock)reject
+ {
+   resolve([[UIDevice currentDevice] identifierForVendor].UUIDString);
+ }

The synchronous bridge call the old module relied on simply doesn't exist under the New Architecture, so every one of these had to become a proper asynchronous, Promise-based method before codegen would generate a working spec for it.

Until that fork was published to our own private registry, we pinned it in place with a resolution override so the rest of the team wasn't blocked on the migration finishing:

{
  "resolutions": {
    "internal-analytics-bridge": "npm:@ourorg/internal-analytics-bridge-fork@1.0.0"
  }
}

That override came out once the fork had its own proper release; it was always meant as a stopgap, not a permanent fixture of the dependency tree.

We also used the mandatory nature of this migration as an opportunity to audit each app's native dependency list more broadly, not just for New Architecture compatibility but for whether each dependency was still worth carrying at all. A couple of native modules turned out to be handling functionality that had since become available through more actively maintained alternatives, or in one case, through a capability that had been added to React Native's own core since the dependency was first introduced. Removing those unnecessary dependencies as part of the same migration effort was more efficient than treating it as a separate future cleanup project nobody would prioritize on its own.

Testing coverage mattered more during this migration than it usually does for a typical feature change, since the New Architecture's different underlying behavior around native module communication meant that even functionally correct code could behave subtly differently under load or in specific timing-sensitive scenarios. We leaned heavily on each app's existing automated test suite to catch regressions, and the apps with thinner test coverage going in required correspondingly more manual verification effort to reach the same confidence level before shipping.

The kind of regression we were most worried about wasn't a crash, it was a timing-sensitive interaction that only misbehaves under load, so we added tests that exercise the module under repeated rapid calls rather than just a single happy-path invocation:

// __tests__/analyticsBridge.test.js
import { getDeviceId } from '../src/native/analyticsBridge';

test('resolves consistently under rapid repeated calls', async () => {
  const results = await Promise.all(
    Array.from({ length: 50 }, () => getDeviceId())
  );

  expect(new Set(results).size).toBe(1);
});

That test would have passed trivially under the old synchronous bridge; under the New Architecture's asynchronous native module bridge it caught a real race in an early draft of the port, where concurrent calls occasionally resolved with a stale cached value.

The broader lesson we're carrying forward: a deprecation deadline that feels comfortably far away is exactly the kind of deadline that's easiest to under-prioritize until it isn't far away anymore. We're treating the next similarly telegraphed platform deprecation with more urgency specifically because of how much more expensive this one became for the app we let slide the longest.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients