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)
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
Mixed‑architecture binaries – When
newArchEnabledis true, the native build system expects both the oldReactPackagemodules and the new TurboModules to be compiled with the same C++ ABI. If a module is still linked against the legacyReactCommonsymbols while the rest of the app is built for the new architecture, the loader cannot resolve the symbols, leading to theUnsatisfiedLinkErrorabove.Codegen generation timing – The code‑gen step (
./gradlew generateCodegenArtifacts) runs once per build. If a module’s.cppimplementation 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.Gradle configuration drift – The
react-native-gradle-pluginversion 0.71.4 introduced a newenableTurboModulesproperty. Projects that still use the olderreact-nativeplugin version (0.68‑0.70) will not apply the requiredCMakeLists.txtpatches, resulting in a partially‑configured build that works locally (where caches hide the problem) but fails on clean CI agents.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"
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"
}
}
}
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');
Why: This file is the source of truth for the code‑gen tool.
b. Run the code‑gen task
./gradlew generateTurboModulesSpec
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
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;
}
Why: Allows the runtime to locate the module by name.
e. Build the turbo flavour
./gradlew assembleTurboDebug
Verify: The build should succeed. Install the APK on a device and call the JS API:
import NativeAnalytics from './NativeAnalyticsModule';
NativeAnalytics.logEvent('test');
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 ]
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:
- Remove the
legacyflavour frombuild.gradle. - Set
NEW_ARCH_ENABLEDtotruein the defaultreleasebuild config. - Delete the old
ReactPackageregistration code fromMainApplication.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
- Identify the constraint – the team cannot afford a release freeze, so the migration must be incremental.
-
Explain the failure mode – mixing legacy
ReactPackagemodules with TurboModules leads to linker errors (UnsatisfiedLinkError) and runtime crashes on clean builds. - 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.
-
Give the concrete plan – create a dedicated
turboproduct flavour, convert one low‑risk module at a time, run parallel CI jobs forlegacyandturbo, and only switch the default flavour after all modules are verified. - 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
ReactPackageregistrations 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)