TL;DR: The legacy React Native bridge causes UI lag and frame drops due to asynchronous JSON serialization across threads. By migrating to the New Architecture (Fabric, TurboModules, and JSI), you can enable direct, synchronous C++ memory access between JavaScript and native code. This guide demonstrates how to enable these features in your platform configs and use Codegen to build strictly typed, synchronous TurboModules.
⚡ Key Takeaways
- Replace asynchronous legacy bridge modules (
RCT_EXPORT_METHOD) with JSI to eliminate JSON serialization and thread-hopping latency. - Enable the New Architecture in Android by setting
newArchEnabled=trueandhermesEnabled=trueinsideandroid/gradle.properties. - Trigger React Native's Codegen on iOS by running
RCT_NEW_ARCH_ENABLED=1 bundle exec pod installto automatically generate the required C++ bindings. - Build synchronous native methods by defining strictly typed TypeScript specifications (e.g.,
export interface Spec extends TurboModule) rather than relying on asynchronous callbacks or Promises. - Utilize the Hermes engine to allow JavaScript to hold direct references to C++ Host Objects, achieving native-level 60fps performance for data-heavy interfaces.
You call a native method to fetch device sensor data. Your JavaScript thread serializes the payload into a JSON string. The payload waits in a queue. A C++ bridge picks it up, deserializes it, and dispatches it to the Objective-C or Java thread. The native thread processes the data, serializes the response back to JSON, and tosses it back across the bridge.
By the time your React component receives the data, three frames have dropped.
If you are building data-heavy interfaces, complex animations, or real-time maps, the legacy React Native Bridge is your primary performance bottleneck. Asynchronous, batched JSON serialization works fine for basic CRUD apps, but it breaks down under heavy load. The bridge queue clogs, native modules respond a frame too late, and rapid scrolling results in blank white screens because the UI thread is waiting for the JS thread to catch up.
The solution is the React Native New Architecture: JSI (JavaScript Interface), Fabric (the new rendering system), and TurboModules (the new native module system). By replacing asynchronous bridge serialization with synchronous, direct C++ memory access, you can achieve native-level 60fps (or 120fps) performance.
This guide breaks down the technical migration path to eliminate bridge lag for good.
The Core Problem: The Legacy Bridge Bottleneck
To understand why the New Architecture is necessary, you must understand how the old bridge fundamentally limits execution speed.
In the old architecture, JavaScript and Native code operate in entirely isolated realms. They communicate exclusively by passing asynchronous JSON messages over a message queue.
// ios/LegacySensorModule.m
#import <React/RCTBridgeModule.h>
@interface LegacySensorModule : NSObject <RCTBridgeModule>
@end
@implementation LegacySensorModule
RCT_EXPORT_MODULE();
// This is completely asynchronous. JS cannot wait for the return value directly.
RCT_EXPORT_METHOD(getSensorData:(RCTResponseSenderBlock)callback)
{
NSDictionary *sensorData = @{@"x": @(1.2), @"y": @(3.4), @"z": @(0.8)};
// Data is serialized to JSON, queued, and sent back across the bridge
callback(@[sensorData]);
}
@end
Because every standard RCT_EXPORT_METHOD is asynchronous, writing synchronous native hooks is highly restrictive. While legacy synchronous workarounds existed, they came with severe performance penalties and disabled remote debugging. When delivering complex React Native mobile app development services to our enterprise clients, we often found that synchronizing JS-driven animations with native scroll events using the legacy bridge was nearly impossible. The latency meant the UI was perpetually one frame behind the user's finger.
JSI completely bypasses this issue. Instead of a message queue, JSI allows the JS engine (Hermes) to hold direct references to C++ Host Objects. When JavaScript invokes a method on a TurboModule, it executes synchronously via a C++ vtable. No JSON serialization. No bridge queue. No thread hopping.
Enabling the New Architecture
Before migrating custom modules, you must enable the New Architecture at the application level. Ensure you are running at least React Native 0.71+, though 0.73 or newer is highly recommended for stable Codegen and Fabric support.
First, enable the New Architecture flag in your Android configuration:
# android/gradle.properties
# Enable the New Architecture (Fabric, TurboModules, and Codegen)
newArchEnabled=true
# Use Hermes engine (Mandatory for optimal JSI performance)
hermesEnabled=true
Next, enable it for iOS by reinstalling your CocoaPods with the required environment variable:
# In the root of your project
cd ios
RCT_NEW_ARCH_ENABLED=1 bundle exec pod install
Production Note: Running
pod installwithRCT_NEW_ARCH_ENABLED=1triggers React Native's Codegen. This tool reads your TypeScript specifications and automatically generates the heavily optimized C++ boilerplate required for JSI bindings.
Building a TurboModule: Synchronous Native Execution
Migrating a legacy module to a TurboModule requires shifting to a specification-first approach. Instead of writing the native code and letting React Native guess the bridge types, you define a strictly typed TypeScript interface. Codegen then generates the C++ protocols that your Objective-C++ or Java/Kotlin code will implement.
Here is how to define a TurboModule specification for a synchronous device calculator:
// tm/NativeCalculator.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
// Notice this returns a direct number, not a Promise.
// This maps to a synchronous C++ call.
add(a: number, b: number): number;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeCalculator');
Once Codegen runs, it outputs a C++ spec interface. You then implement this in Objective-C++ (note the .mm extension, which is required to mix Objective-C and C++).
// ios/NativeCalculator.mm
#import "NativeCalculator.h"
#import <React/RCTLog.h>
// This header is automatically generated by Codegen
#import "NativeCalculatorSpec.h"
// The module must conform to the Codegen-generated protocol
@interface NativeCalculator () <NativeCalculatorSpec>
@end
@implementation NativeCalculator
RCT_EXPORT_MODULE()
// The method signature must match the generated Objective-C protocol exactly
- (NSNumber *)add:(double)a b:(double)b {
RCTLogInfo(@"Synchronous execution on the Native thread via JSI!");
return @(a + b);
}
// Required boilerplate to connect the module to the JSI Spec
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
return std::make_shared<facebook::react::NativeCalculatorSpecJSI>(params);
}
@end
By leveraging TurboModules, your app no longer initializes every native module at startup. They are lazy-loaded upon first invocation, significantly reducing Time to Interactive (TTI).
Migrating UI Components to Fabric
Fabric is the new UI rendering system that replaces the legacy asynchronous shadow tree. In the old architecture, React tells the native layer to draw a <View> via a bridge message. With Fabric, React manipulates a C++ shadow tree directly via JSI. This enables React 18 Concurrent Mode features, allowing you to interrupt rendering for high-priority UI updates.
To create a Fabric component, you start with a strongly typed TypeScript specification.
// fabric/CustomGradientViewNativeComponent.ts
import type { ViewProps } from 'react-native/Libraries/Components/View/ViewPropTypes';
import type { HostComponent } from 'react-native';
import type { Int32 } from 'react-native/Libraries/Types/CodegenTypes';
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
export interface NativeProps extends ViewProps {
colorStart: string;
colorEnd: string;
// Int32 ensures strict C++ memory allocation via Codegen
borderRadius?: Int32;
}
export default codegenNativeComponent<NativeProps>(
'CustomGradientView'
) as HostComponent<NativeProps>;
Warning: Fabric enforces strict typing. If you pass a string to a property defined as a
DoubleorInt32in your Spec, your app will crash in development. This strictness is exactly what allows the C++ layer to operate synchronously without the overhead of runtime type checking.
When managing state in highly dynamic apps—like the real-time tracking screens we built for logistics platforms such as Driftload—Fabric eliminates the visual "tearing" that occurs when JS state updates faster than the native UI thread can paint.
Auditing Your Dependency Tree
Before you can fully switch your production app to the New Architecture, you must ensure that all third-party libraries in your node_modules support Fabric and TurboModules. While React Native provides an interoperability layer, relying on it negates the performance benefits of JSI.
You can run a quick audit of your dependencies using a bash script to check for modern Codegen specs:
#!/bin/bash
# audit-new-arch.sh
# Finds libraries that have explicitly defined Codegen specs
echo "Scanning node_modules for New Architecture specifications..."
find ./node_modules -maxdepth 3 -type f -name "package.json" | while read -r file; do
if grep -q '"codegenConfig"' "$file"; then
libname=$(jq -r '.name' "$file")
echo "✅ Ready: $libname"
fi
done
If critical libraries (like navigation, maps, or camera utilities) are not yet updated, you must either contribute to those open-source libraries or fork them to implement the C++ JSI bindings yourself.
Structuring Your Migration Sprints
Migrating a massive legacy application to Fabric and TurboModules is not a weekend task. It requires meticulous sprint planning and engineering processes.
We recommend a feature-flagged approach. You can build wrapper components that conditionally render either the legacy component or the Fabric component based on the global architecture state.
// components/MigrationGuard.tsx
import React from 'react';
// React Native provides an internal global variable to check the architecture
const isFabricEnabled = (global as any).nativeFabricUIManager != null;
import { LegacyCustomView } from './LegacyCustomView';
import FabricCustomView from '../fabric/CustomGradientViewNativeComponent';
interface Props {
colorStart: string;
colorEnd: string;
}
export const SafeGradientView: React.FC<Props> = (props) => {
if (isFabricEnabled) {
return <FabricCustomView {...props} />;
}
// Fallback for Old Architecture environments during the transition
return <LegacyCustomView {...props} />;
};
This strategy allows your team to migrate incrementally, ensuring your CI/CD pipelines and QA processes remain stable while you rewrite the native C++ layers. Understanding the ROI and technical debt migration pricing is critical here—focus your migration efforts first on the views that suffer from the most frame drops, like infinite scrolling lists and interactive gesture handlers.
Unlocking Uncompromised Mobile Performance
The legacy React Native bridge was an ingenious solution for its time, but it fundamentally bottlenecked high-performance mobile applications. By migrating to the New Architecture, you are stripping away the JSON serialization overhead and enabling direct C++ memory sharing between JavaScript and native threads.
Fabric guarantees synchronous UI layout calculation, and TurboModules guarantee that native code executes exactly when you invoke it—not a frame later. The migration requires an investment in strongly typed specifications and C++ boilerplate, but the resulting 60fps performance puts React Native directly on par with purely native Swift and Kotlin applications.
If you are dealing with complex tech debt or need an expert team to unblock your New Architecture migration, book a free architecture review with our senior engineering team today.
Work With Us
Need help building this in production? SoftwareCrafting is a full-stack dev agency — we ship React, Next.js, Node.js, React Native & Flutter apps for global clients.
Frequently Asked Questions
Why am I seeing [object Object] instead of my actual data?
This happens when JavaScript attempts to implicitly convert an object into a string. The default toString() method on the base Object.prototype returns the literal string "[object Object]". To fix this, you need to explicitly access the object's properties or serialize the entire object.
How can I properly stringify an object to view its contents?
The most common and effective approach is to use JSON.stringify(myObject). For better readability during debugging, you can pass additional arguments like JSON.stringify(myObject, null, 2) to pretty-print the output with indentation. This ensures you see the actual key-value pairs instead of the default string representation.
What causes [object Object] to appear in my React UI?
In React, this typically occurs when you try to render a raw JavaScript object directly inside JSX curly braces, such as <div>{myData}</div>. React expects primitives like strings or numbers, or valid React elements, not plain objects. You must map over the object, access specific string properties, or serialize it before rendering it to the DOM.
How can SoftwareCrafting services help my team prevent implicit coercion bugs?
SoftwareCrafting services provide comprehensive code reviews and automated testing setups that catch implicit type coercion bugs before they reach production. By implementing strict TypeScript configurations and modern linting rules, our experts ensure your team avoids unexpected [object Object] outputs entirely. We focus on building robust, type-safe architectures tailored to your specific application needs.
Why does string concatenation with an object result in this error?
When you use the + operator to combine a string and an object (e.g., "User data: " + obj), JavaScript automatically coerces the object into a string to complete the operation. This triggers the object's toString() method, resulting in the unhelpful "User data: [object Object]" output. Using template literals with proper property access prevents this issue.
Can SoftwareCrafting services assist with refactoring legacy codebases prone to serialization errors?
Yes, when dealing with legacy codebases plagued by data serialization issues, SoftwareCrafting services conduct deep architectural audits to identify the root causes. We systematically refactor brittle data-handling logic into predictable, strongly-typed patterns. This hands-on approach eliminates persistent rendering bugs while vastly improving your overall application maintainability.
📎 Full Code on GitHub Gist: The complete
debug-n8n-workflow.jsfrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
