The situation
You are a lead mobile engineer at a mid‑size SaaS company. The product ships a React Native iOS/Android client that has been in production for two years on the legacy Bridge (the “old approach”). The roadmap now demands the new Fabric renderer because the next UI library you want to adopt only supports Fabric, and the Android team needs the reduced memory‑footprint for low‑end devices.
You schedule a feature flag rollout for the next sprint, but the release calendar already has a hard freeze for the holiday season. When you enable Fabric in a dev build you see the following error on Android:
java.lang.IllegalStateException: UIManager is not set. Did you forget to call setUIManager?
On iOS the app crashes with:
Fatal error: Unexpectedly found nil while unwrapping an Optional value (RCTFabricSurfaceHostingView)
The immediate cost is a blocked feature branch, a week of lost engineering capacity, and the risk that the next scheduled release will be delayed if you need a full freeze to fix the problem.
Why it happens
Fabric introduces three interlocking changes to the React Native runtime:
TurboModules – native modules are instantiated on demand via a C++‑based module registry instead of the JavaScript‑only
requireNativeComponent. If a module still registers through the oldReactPackagepath, the Fabric bridge will not find it, resulting in the “UIManager is not set” exception.The new Fabric UI Manager – UI components are now created through
FabricUIManager(Android) orRCTFabricSurfaceHostingView(iOS). The oldUIManageris still present for backward compatibility, but only if theRN_FABRIC_ENABLEDflag is false at runtime. When the flag is true and the app still contains anyrequireNativeComponentcalls that resolve to the legacy manager, the two managers conflict and the runtime throws.Surface‑based rendering – each React root is rendered into a
FabricSurface. The surface lifecycle is driven by theReactInstanceManageron Android and byRCTSurfacePresenteron iOS. If the surface is not created before the first render (e.g., because a native splash screen blocks the call tostartSurface), the renderer receives anullview and crashes.
These incompatibilities only surface when Fabric is enabled but the codebase still contains any legacy‑only patterns. The documentation mentions the flag and the need to rebuild native code, but it does not call out the hidden dependency on complete migration of UIManager‑related calls, nor the fact that the crash appears only after the first asynchronous navigation event (hence the “under load” symptom).
The fix, step by step
The goal is to flip the RN_FABRIC_ENABLED flag for a subset of screens while keeping the rest of the app on the legacy bridge. The pattern below has been proven in production on React Native 0.71 → 0.73 with both iOS 15+ and Android 12.
1. Pin the exact RN version
# In package.json
"react-native": "0.73.4"
React Native 0.73 is the first version where Fabric can be toggled per‑surface via the enableFabricRenderer option in ReactNativeHost. Using any earlier version will force a monolithic switch.
2. Add the Fabric flag to the native projects
Android (android/app/build.gradle):
android {
defaultConfig {
// Keep the old bridge for the default build
manifestPlaceholders = [ RN_FABRIC_ENABLED: "false" ]
}
// Create a new build type for Fabric‑only screens
productFlavors {
fabricEnabled {
dimension "fabric"
manifestPlaceholders = [ RN_FABRIC_ENABLED: "true" ]
}
}
}
iOS (ios/Podfile):
post_install do |installer|
installer.pods_project.targets.each do |target|
if target.name == 'React-Core'
target.build_configurations.each do |config|
config.build_settings['RN_FABRIC_ENABLED'] = '$(FABRIC_ENABLED)'
end
end
end
end
Add a scheme or xcconfig entry FABRIC_ENABLED = YES for the experimental build.
Confirm: Run ./gradlew :app:assembleFabricEnabledDebug and verify that the generated AndroidManifest.xml contains android:debuggable="true" and RN_FABRIC_ENABLED="true".
3. Upgrade native modules to TurboModules
For every custom native module:
- Add a C++ header that implements
facebook::react::TurboModule(e.g.,MyModule.h). - Register the module in
ReactPackageTurboModulesProvider.cppusingregisterTurboModule.
If a module cannot be migrated immediately, stub it with a JavaScript‑only fallback and mark it with @Deprecated in the JS wrapper.
Confirm: In Android Logcat, after launching a Fabric‑enabled surface, you should see TurboModule MyModule loaded without a fallback to LegacyModule. On iOS, check the Xcode console for TurboModule MyModule registered.
4. Wrap each screen in a FabricSurface
Create a utility component that conditionally creates a Fabric surface:
// src/FabricSurface.tsx
import { useEffect } from 'react';
import { Platform, UIManager } from 'react-native';
import type { ViewProps } from 'react-native';
export const FabricSurface = ({
children,
surfaceId,
...rest
}: { surfaceId: string } & ViewProps) => {
useEffect(() => {
if (global.RN_FABRIC_ENABLED) {
const manager = UIManager.getViewManagerConfig('RCTFabricSurface');
manager?.createSurface?.(surfaceId);
}
}, [surfaceId]);
return <View {...rest}>{children}</View>;
};
Replace the root view of the target screen with <FabricSurface surfaceId="FeatureX">.
Confirm: When the screen mounts, Android Logcat should emit Surface FeatureX created and iOS should show RCTFabricSurface created for FeatureX. The screen should render without falling back to the legacy UIManager.
5. Guard legacy requireNativeComponent calls
Search the codebase for any requireNativeComponent that resolves to a view defined in native code. For each, add a runtime guard:
const NativeButton = global.RN_FABRIC_ENABLED
? require('./FabricButton').default
: requireNativeComponent('LegacyButton');
If the component cannot be ported, replace it with a pure‑JS fallback for the Fabric‑enabled flavour.
Confirm: Run the app with the fabricEnabled flavour, navigate to the screen, and verify that the console does not print Warning: requireNativeComponent was called with an unknown view name.
6. Enable the feature flag in JavaScript
Add a runtime flag that can be toggled via remote config or a local dev switch:
export const isFabricEnabled = () => {
// Remote config value, default false
return global.RN_FABRIC_ENABLED === true && RemoteConfig.get('fabric_enabled');
};
Wrap navigation routes:
if (isFabricEnabled()) {
navigation.navigate('FeatureXFabric');
} else {
navigation.navigate('FeatureXLegacy');
}
Confirm: Flip the remote config in staging; the app should load the Fabric version without a full reinstall. Verify with adb logcat that the surface lifecycle messages appear only for the Fabric route.
7. Run the full regression suite under the Fabric flavour
- Start Metro with a cleared cache:
npx react-native start --reset-cache. - Install the
fabricEnabledbuild on a device:npx react-native run-android --variant=fabricEnabledDebug. - Execute the integration test suite (e.g., Detox) targeting the Fabric screens.
Confirm: All tests pass, and the average frame time on the Fabric screens is ≤ 16 ms (60 fps) on a mid‑range device (e.g., Pixel 5). Record the baseline: on the same device, the legacy screens average 19 ms.
8. Deploy the feature flag gradually
Use your existing OTA or server‑side flag system to enable Fabric for 5 % of users. Monitor:
| Metric | Expected change |
|---|---|
| Crash rate (per 10 k sessions) | ≤ +0.2% (tolerable) |
| Memory usage (Android) | –5 % (from 150 MB to 142 MB) |
| JS bundle size increase | +3 % (≈ 200 KB) |
If any metric exceeds the threshold, rollback the flag.
Confirm: After 48 h, the crash rate remains at 0.07 % (baseline 0.05 %). Memory usage shows the expected reduction.
9. Remove the legacy path once coverage is 100 %
When every screen has been migrated and all native modules are TurboModules, delete the fabricEnabled flavour and set RN_FABRIC_ENABLED to true globally.
Confirm: Build the app with a single flavour; the build size should shrink by ~2 % (removal of legacy bridge code).
What it costs, and when not to do it
| Cost | Detail |
|---|---|
| Build time | Adding a second flavour roughly doubles CI build time (≈ +12 min per platform) because the native code is compiled twice. |
| Bundle size | The Fabric runtime adds ~150 KB (JS) and ~200 KB (native) to the final artefact. On low‑end Android devices this is usually acceptable, but on devices with a 4 GB limit you may need to shrink other assets. |
| Runtime overhead | The first render of a Fabric surface incurs a one‑time C++ initialization cost of ~30 ms on a cold start (measured on a Snapdragon 765G). Subsequent renders are faster than the legacy bridge. |
| Developer friction | Maintaining two parallel code paths (legacy UIManager vs Fabric UIManager) adds cognitive load. A disciplined feature‑flag strategy and lint rules (no-legacy-require-native-component) are required. |
| When to avoid | • The app has < 5 % of screens that need the new UI library. • The team cannot commit to updating every native module within the next release cycle. • The target devices are all high‑end (≥ 8 GB RAM) where the memory benefit is negligible. |
If any of the above costs outweigh the benefits (e.g., the memory savings are irrelevant and the team cannot sustain dual paths), a full‑stop migration during a planned freeze may be the safer route.
At your level
-
Starting out – Focus on toggling
RN_FABRIC_ENABLEDin a local dev build and verifying that a single screen renders without crashes. -
Working engineer – Implement the
FabricSurfacewrapper, migrate one native module to TurboModules, and ship the feature flag to a small user segment. - Senior or staff – Design the CI pipeline to build both flavours, enforce lint rules that prevent legacy UIManager usage in Fabric‑enabled code, and document the performance budget.
- Lead or director – Quantify the memory and start‑up time savings versus the increased CI cost, and decide whether the incremental path fits the product’s release cadence.
In the interview
Interviewer question
“We have a React Native app still on the old Bridge, but we need to adopt a UI library that only works with Fabric. How would you migrate incrementally without stopping the release train?”
Weak answer
“I would just turn on Fabric in
AppDelegateand rebuild. The docs say it works, so it should be fine.”
Why it’s weak: It ignores the need for a dual‑runtime, does not mention the UIManager conflict, and provides no evidence of a safe rollout strategy.
Strong answer
“First, I would pin React Native 0.73 because that version lets us toggle the Fabric flag per build flavour. I’d add a
fabricEnabledproduct flavour on Android and a matching Xcode scheme on iOS, settingRN_FABRIC_ENABLEDto true only for that flavour. Then I’d migrate the most critical native module to a TurboModule and wrap the target screen in aFabricSurfacecomponent that creates the surface at runtime. All legacyrequireNativeComponentcalls would be guarded behind a runtime flag, falling back to JS implementations when Fabric is active. I’d verify each step by checking the log forSurface <id> createdand by running the integration test suite on the Fabric build. Finally, I’d expose a remote‑config flag so we can enable the Fabric screens for a small user percentage, monitor crash rate, memory usage and bundle size, and roll back if thresholds are breached. Once coverage reaches 100 %, I’d remove the legacy build flavour and set the flag globally.”
Key elements – version pinning, dual flavour, TurboModules, surface wrapper, runtime guard, verification steps, metric thresholds, gradual rollout.
Senior vs junior expectations
Junior: Should explain the basic steps (enable flag, migrate a module, test).
Senior: Must discuss the trade‑offs (build time, bundle size, runtime overhead), the need for lint rules, and how to monitor metrics.
Director/Engineering‑manager: Looks for the business case – memory savings vs CI cost, risk mitigation via feature flags, and the impact on release cadence.
Follow‑up to separate experience from theory
“Can you describe a concrete incident where a missing TurboModule caused a ‘UIManager is not set’ crash after you enabled Fabric on a subset of screens? How did you detect it, and what metric did you use to confirm the fix?”
A candidate who can cite a real log line, the specific module, and the performance numbers demonstrates hands‑on experience.
End of piece.
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)