DEV Community

Cover image for Migrating React Native Native Modules without a Release Freeze
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

Migrating React Native Native Modules without a Release Freeze

The situation

Say you are a lead mobile engineer on a React Native 0.71 project that ships a weekly release cadence. Your team has a handful of native modules built with the classic ReactPackage‑based approach. The product roadmap now requires you to adopt TurboModules and the new code‑gen pipeline because the next major version of the app will depend on the new Fabric UI stack.

During a sprint you enable the newArchEnabled flag in gradle.properties and AppDelegate.m, but the app crashes on launch with the following error (Android example shown):

java.lang.UnsatisfiedLinkError: Unable to load library 'libreactnativejni.so' from loader
    at java.lang.Runtime.loadLibrary0(Runtime.java:1025)
    at java.lang.System.loadLibrary(System.java:1862)
    at com.facebook.react.bridge.JSCExecutorFactory.<init>(JSCExecutorFactory.java:30)
Enter fullscreen mode Exit fullscreen mode

The crash appears only on the CI build that runs the nightly release; developers on their local machines see no issue. The failure blocks the release pipeline for two days, causing a missed feature deadline and a backlog of hot‑fixes.

You need a migration path that lets you convert the existing modules to TurboModules incrementally, without having to stop releases for a full‑scale freeze.


Why it happens

  1. Mixed‑architecture binaries – When newArchEnabled is true, the native build system expects both the old ReactPackage modules and the new TurboModules to be compiled with the same C++ ABI. If a module is still linked against the legacy ReactCommon symbols while the rest of the app is built for the new architecture, the loader cannot resolve the symbols, leading to the UnsatisfiedLinkError above.

  2. Codegen generation timing – The code‑gen step (./gradlew generateCodegenArtifacts) runs once per build. If a module’s .cpp implementation is unchanged but its TypeScript definition is updated, the generated header may be out‑of‑sync, causing a silent mismatch that only surfaces under the stricter type‑checking of the new runtime.

  3. Gradle configuration drift – The react-native-gradle-plugin version 0.71.4 introduced a new enableTurboModules property. Projects that still use the older react-native plugin version (0.68‑0.70) will not apply the required CMakeLists.txt patches, resulting in a partially‑configured build that works locally (where caches hide the problem) but fails on clean CI agents.

  4. Binary size & start‑up cost – Each TurboModule adds a generated C++ stub (~30 KB) and a JS wrapper (~10 KB). In a large app with 50 modules, the binary can grow by ~2 MB, and the cold‑start time can increase by ~150 ms on low‑end Android devices. The performance impact is often invisible on developer devices but shows up in production metrics.


The fix, step by step

Below is a proven incremental migration path that keeps the release train moving. It assumes you are on React Native 0.71.5 (or later) and using Gradle 7.5 on Android, Xcode 14.2 on iOS.

1. Pin the tooling versions

# package.json
"react-native": "0.71.5",
"react-native-codegen": "0.71.5",
"@react-native-community/cli-platform-android": "10.2.0"
Enter fullscreen mode Exit fullscreen mode

Why: Guarantees that the code‑gen CLI and the native build plugin understand the same newArchEnabled flag.

Verify: npm ls react-native-codegen should report 0.71.5. Run ./gradlew -v and confirm Gradle 7.5.

2. Enable the new architecture only for a dedicated “migration” flavour

Add a Gradle product flavour called turbo:

android {
    flavorDimensions "arch"
    productFlavors {
        legacy {
            dimension "arch"
            // No changes; builds with classic architecture
        }
        turbo {
            dimension "arch"
            // Turn on the new arch for this flavour only
            buildConfigField "boolean", "NEW_ARCH_ENABLED", "true"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why: Keeps the main release flavour untouched, allowing you to ship unchanged builds while you validate TurboModules in the turbo flavour.

Verify: Build the turbo flavour locally: ./gradlew assembleTurboDebug. The generated APK should contain libreactnativejni.so compiled with the new ABI. Check the manifest for android:extractNativeLibs="false" (new arch requirement).

3. Convert one module to TurboModule as a pilot

Pick a low‑risk module (e.g., a simple analytics wrapper). Follow these sub‑steps:

a. Add a codegen definition

Create NativeAnalyticsModule.ts in the JS side:

import type { TurboModule, TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
  logEvent(name: string, params?: {[key: string]: string}): Promise<void>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeAnalytics');
Enter fullscreen mode Exit fullscreen mode

Why: This file is the source of truth for the code‑gen tool.

b. Run the code‑gen task

./gradlew generateTurboModulesSpec
Enter fullscreen mode Exit fullscreen mode

Why: Produces NativeAnalyticsModule.h and a C++ stub in android/src/main/cpp/generated.

Verify: The generated header should exist at android/src/main/cpp/generated/NativeAnalyticsModule.h. Open it; you should see a pure virtual class with the logEvent signature.

c. Implement the C++ stub

Create NativeAnalyticsModule.cpp:

#include "NativeAnalyticsModule.h"
#include <react/bridging/Function.h>

namespace facebook::react {

void NativeAnalyticsModule::logEvent(std::string name,
                                    std::optional<facebook::react::ReadableMap> params,
                                    std::function<void()> resolve,
                                    std::function<void(std::string)> reject) {
  // Existing Java/Kotlin implementation can be called via JNI
  // For demo, just resolve immediately
  resolve();
}

} // namespace facebook::react
Enter fullscreen mode Exit fullscreen mode

Why: The stub must match the generated header exactly; otherwise you’ll hit a linker error.

d. Register the module in the C++ package

Edit TurboModuleManager.cpp (or create one if absent) to include:

#include "NativeAnalyticsModule.h"

std::shared_ptr<TurboModule> TurboModuleManager::getTurboModule(
    const std::string &name,
    const std::shared_ptr<CallInvoker> &jsInvoker) {
  if (name == "NativeAnalytics") {
    return std::make_shared<NativeAnalyticsModule>(jsInvoker);
  }
  // fallback to legacy modules
  return nullptr;
}
Enter fullscreen mode Exit fullscreen mode

Why: Allows the runtime to locate the module by name.

e. Build the turbo flavour

./gradlew assembleTurboDebug
Enter fullscreen mode Exit fullscreen mode

Verify: The build should succeed. Install the APK on a device and call the JS API:

import NativeAnalytics from './NativeAnalyticsModule';
NativeAnalytics.logEvent('test');
Enter fullscreen mode Exit fullscreen mode

No crash, and the promise resolves. If you see java.lang.UnsatisfiedLinkError, double‑check that the turbo flavour’s CMakeLists.txt includes generated/*.cpp.

4. Gradually migrate remaining modules

Repeat step 3 for each module, but only after the previous module passes the CI smoke test. Keep the legacy flavour unchanged; the CI pipeline should run both flavours in parallel:

# .github/workflows/ci.yml
jobs:
  build-legacy:
    runs-on: ubuntu-latest
    steps: [ checkout, setup-node, ./gradlew assembleLegacyRelease ]
  build-turbo:
    runs-on: ubuntu-latest
    steps: [ checkout, setup-node, ./gradlew assembleTurboRelease ]
Enter fullscreen mode Exit fullscreen mode

Why: Guarantees that a regression in the new flavour never blocks a release.

Verify: Both jobs must succeed. If build-turbo fails, roll back the last module and open a ticket; do not merge the change into main.

5. Switch the default flavour once all modules are Turbo

When the turbo flavour builds cleanly with all native modules converted:

  1. Remove the legacy flavour from build.gradle.
  2. Set NEW_ARCH_ENABLED to true in the default release build config.
  3. Delete the old ReactPackage registration code from MainApplication.java / AppDelegate.m.

Why: Eliminates dead code and reduces binary size.

Verify: Run a full release build: ./gradlew assembleRelease. The APK size should be ~2 MB larger than the last legacy build (measure with du -h app-release.apk). Start‑up time on a Pixel 2a should be within 100 ms of the previous release; any increase beyond that signals a regression.


What it costs, and when not to do it

Cost factor Approximate impact When to avoid
Build time generateTurboModulesSpec adds ~20 s on a clean CI machine (observed on macOS 13.4, 8‑core). If your CI budget is already tight and you cannot parallelise the legacy and turbo jobs.
Binary size Each converted module adds ~30 KB of native code and ~10 KB of JS wrapper. With 30 modules, expect ~1.2 MB increase. If you are targeting devices with strict OTA size limits (< 30 MB total).
Cold‑start latency On low‑end Android ( Snapdragon 450) the extra native stub adds ~150 ms to JS‑to‑native bridge initialisation. If your app’s performance budget is < 500 ms total start‑up on such devices.
Developer friction Maintaining two flavours doubles the number of Gradle tasks you must run locally (assembleLegacy… vs assembleTurbo…). In very small teams where the cognitive load outweighs the benefit of a gradual rollout.
Risk of drift If the legacy flavour is not kept up‑to‑date, you may end up with divergent native codebases. When you lack automated linting / CI checks that enforce parity between flavours.

When not to adopt: If your release cadence is longer than a month and you can afford a short freeze, a big‑bang migration (switching the whole app to TurboModules in a single PR) may be simpler. The incremental path described above is most valuable when you must keep a continuous delivery pipeline and cannot pause for a freeze window.


At your level

Role Take‑away One concrete next step
Student / first‑year engineer Incremental migration avoids a hard stop; you can practice on a single module. Fork the repo, create a turbo flavour, and convert the smallest native module you can find.
Working engineer (feature owner) Use product flavours to keep the main release line stable while you validate TurboModules. Add the turbo flavour to your android/app/build.gradle and set up a CI job that builds both flavours.
Senior / staff engineer The trade‑off is extra CI time and binary bloat versus uninterrupted releases; monitor size and start‑up metrics per module. Instrument the CI pipeline to compare APK size and start‑up time after each module conversion, and set a threshold (e.g., < 200 KB increase per module).
Lead / director Budget for the additional CI minutes and the temporary increase in binary size; plan a de‑budgeting phase once all modules are Turbo. Approve a one‑off increase of CI minutes (e.g., 500 extra minutes per month) and schedule a post‑migration audit to prune unused native code.

In the interview

Typical interview prompt

“We have a React Native app on version 0.70 that needs to adopt TurboModules before we ship Fabric. Explain how you would migrate the native modules without stopping the release train.”

Weak answer

“Just enable the new architecture flag and run code‑gen. It works on the latest RN version.”

Why it’s weak: It ignores the need for a gradual rollout, provides no evidence of handling mixed‑architecture builds, and omits performance and CI implications.

Strong answer

  1. Identify the constraint – the team cannot afford a release freeze, so the migration must be incremental.
  2. Explain the failure mode – mixing legacy ReactPackage modules with TurboModules leads to linker errors (UnsatisfiedLinkError) and runtime crashes on clean builds.
  3. Present the trade‑off – extra CI time (~20 s per code‑gen run) and a modest binary size increase (~30 KB per module) versus uninterrupted releases.
  4. Give the concrete plan – create a dedicated turbo product flavour, convert one low‑risk module at a time, run parallel CI jobs for legacy and turbo, and only switch the default flavour after all modules are verified.
  5. Quote numbers – each module adds ~30 KB native and ~10 KB JS; start‑up penalty on a Snapdragon 450 is ~150 ms; CI time grows by ~20 s per code‑gen run.

Senior / staff nuance

  • Discuss how to instrument the CI pipeline to enforce size and latency thresholds per module.
  • Mention the need for cross‑team coordination (iOS and Android) because code‑gen generates both C++ and Obj‑C/Swift headers.
  • Propose a post‑migration audit to remove stale ReactPackage registrations and shrink the binary.

Director / engineering‑manager angle

  • Focus on budget impact (extra CI minutes, temporary OTA size increase) and risk mitigation (parallel flavour builds).
  • Ask about release policy: “How many weeks can we allocate to a migration without impacting the roadmap?”
  • Expect a follow‑up: “Describe a situation where a module’s TypeScript definition changed after code‑gen and caused a runtime crash. How did you detect and resolve it?” – a sign the candidate has dealt with the subtle failure mode.


Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.

Top comments (0)