Mastering React Native Native Modules: Building High-Performance Bridge Solutions in 2025
After 5+ years of building React Native applications and creating dozens of custom native modules, I've learned that the bridge between JavaScript and native code is where the real magic happens. Today, I'll share my experience building production-ready native modules, from traditional bridge modules to cutting-edge TurboModules and JSI implementations.
The Evolution of Native Modules
From Bridge to TurboModules
When I first started creating native modules in 2019, the bridge architecture was our only option. While functional, it had significant limitations:
- Asynchronous-only communication
- JSON serialization overhead
- No type safety across the bridge
- Startup performance issues
The introduction of TurboModules and JSI has revolutionized how we build native modules. Let me show you the difference with a practical example.
Legacy Bridge Module vs TurboModule
Here's a comparison using a biometric authentication module I built for a fintech app:
Old Bridge Approach
// BiometricAuth.js (Legacy)
import { NativeModules } from 'react-native';
const { BiometricAuth } = NativeModules;
export const authenticateUser = async () => {
try {
const result = await BiometricAuth.authenticate();
return JSON.parse(result);
} catch (error) {
throw new Error(error.message);
}
};
// BiometricAuth.m (iOS Legacy)
@interface BiometricAuth : NSObject <RCTBridgeModule>
@end
@implementation BiometricAuth
RCT_EXPORT_MODULE();
RCT_EXPORT_METHOD(authenticate:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
LAContext *context = [[LAContext alloc] init];
NSError *error = nil;
if ([context canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&error]) {
[context evaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics
localizedReason:@"Authenticate to access your account"
reply:^(BOOL success, NSError *error) {
if (success) {
NSDictionary *result = @{@"success": @YES, @"timestamp": @([[NSDate date] timeIntervalSince1970])};
NSString *jsonString = [self dictionaryToJSON:result];
resolve(jsonString);
} else {
reject(@"AUTH_FAILED", @"Authentication failed", error);
}
}];
} else {
reject(@"BIOMETRY_NOT_AVAILABLE", @"Biometry not available", error);
}
}
@end
Modern TurboModule Approach
// BiometricAuth.ts (TurboModule)
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface BiometricResult {
success: boolean;
timestamp: number;
biometryType?: 'TouchID' | 'FaceID' | 'Fingerprint';
}
export interface Spec extends TurboModule {
authenticate(): Promise<BiometricResult>;
isAvailable(): boolean;
getSupportedBiometryType(): string | null;
}
export default TurboModuleRegistry.getEnforcing<Spec>('BiometricAuth');
// BiometricAuth.mm (iOS TurboModule)
#import "BiometricAuth.h"
#import <React/RCTUtils.h>
@implementation BiometricAuth
RCT_EXPORT_MODULE()
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
return std::make_shared<facebook::react::NativeBiometricAuthSpecJSI>(params);
}
- (void)authenticate:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject
{
LAContext *context = [[LAContext alloc] init];
NSError *error = nil;
if ([context canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&error]) {
[context evaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics
localizedReason:@"Authenticate to access your account"
reply:^(BOOL success, NSError *error) {
if (success) {
NSString *biometryType = [self getBiometryTypeString:context.biometryType];
resolve(@{
@"success": @YES,
@"timestamp": @([[NSDate date] timeIntervalSince1970]),
@"biometryType": biometryType
});
} else {
reject(@"AUTH_FAILED", @"Authentication failed", error);
}
}];
} else {
reject(@"BIOMETRY_NOT_AVAILABLE", @"Biometry not available", error);
}
}
- (NSNumber *)isAvailable
{
LAContext *context = [[LAContext alloc] init];
NSError *error = nil;
BOOL available = [context canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&error];
return @(available);
}
@end
Performance Gains
The TurboModule version delivered:
- 50% faster initialization
- Type safety at compile time
- Synchronous methods where appropriate
- Better error handling
Advanced Native Module Patterns
1. Event-Driven Architecture with Native Emitters
For real-time features like location tracking or sensor data, I use native event emitters:
// LocationTracker.ts
export interface LocationEvent {
latitude: number;
longitude: number;
accuracy: number;
timestamp: number;
}
export interface Spec extends TurboModule {
startTracking(accuracy: number): void;
stopTracking(): void;
getCurrentLocation(): Promise<LocationEvent>;
// Event emitter methods
addListener(eventName: string): void;
removeListeners(count: number): void;
}
// LocationTracker.mm (iOS)
#import <CoreLocation/CoreLocation.h>
@interface LocationTracker : NSObject <RCTBridgeModule, CLLocationManagerDelegate>
@property (nonatomic, strong) CLLocationManager *locationManager;
@property (nonatomic, assign) BOOL hasListeners;
@end
@implementation LocationTracker
RCT_EXPORT_MODULE()
- (NSArray<NSString *> *)supportedEvents
{
return @[@"onLocationUpdate", @"onLocationError"];
}
- (void)startObserving
{
self.hasListeners = YES;
}
- (void)stopObserving
{
self.hasListeners = NO;
}
- (void)locationManager:(CLLocationManager *)manager
didUpdateLocations:(NSArray<CLLocation *> *)locations
{
if (self.hasListeners) {
CLLocation *location = locations.lastObject;
[self sendEventWithName:@"onLocationUpdate" body:@{
@"latitude": @(location.coordinate.latitude),
@"longitude": @(location.coordinate.longitude),
@"accuracy": @(location.horizontalAccuracy),
@"timestamp": @([location.timestamp timeIntervalSince1970])
}];
}
}
@end
2. Complex Data Structures with Proper Type Mapping
When working with complex native objects, proper type mapping is crucial:
// Camera.ts - Complex camera module
export interface CameraConfig {
quality: 'low' | 'medium' | 'high' | 'ultra';
format: 'jpeg' | 'png' | 'heic';
enableHDR: boolean;
flashMode: 'auto' | 'on' | 'off';
}
export interface CapturedPhoto {
uri: string;
width: number;
height: number;
orientation: number;
metadata: PhotoMetadata;
}
export interface PhotoMetadata {
exif: Record<string, any>;
gps?: GPSData;
timestamp: number;
}
export interface Spec extends TurboModule {
capturePhoto(config: CameraConfig): Promise<CapturedPhoto>;
getAvailableFormats(): Promise<string[]>;
isHDRSupported(): boolean;
}
3. Memory-Efficient Native Modules
For modules that handle large data sets, memory management is critical:
// ImageProcessor.mm - Memory-efficient image processing
@implementation ImageProcessor
- (void)processImage:(NSString *)imagePath
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@autoreleasepool {
UIImage *image = [UIImage imageWithContentsOfFile:imagePath];
if (!image) {
reject(@"INVALID_IMAGE", @"Could not load image", nil);
return;
}
// Process image with memory-efficient techniques
UIImage *processedImage = [self processImageWithEfficiency:image];
// Save and return path
NSString *outputPath = [self saveImageToTempDirectory:processedImage];
dispatch_async(dispatch_get_main_queue(), ^{
resolve(@{@"outputPath": outputPath});
});
}
});
}
- (UIImage *)processImageWithEfficiency:(UIImage *)image
{
// Use Core Graphics for memory-efficient processing
CGImageRef cgImage = image.CGImage;
size_t width = CGImageGetWidth(cgImage);
size_t height = CGImageGetHeight(cgImage);
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, colorSpace, kCGImageAlphaPremultipliedLast);
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
// Apply filters using Core Image
CIContext *ciContext = [CIContext contextWithOptions:nil];
CIImage *ciImage = [CIImage imageWithCGImage:cgImage];
// Apply your filters here
CIFilter *filter = [CIFilter filterWithName:@"CIGaussianBlur"];
[filter setValue:ciImage forKey:kCIInputImageKey];
[filter setValue:@(2.0) forKey:kCIInputRadiusKey];
CIImage *outputImage = filter.outputImage;
CGImageRef outputCGImage = [ciContext createCGImage:outputImage fromRect:outputImage.extent];
UIImage *result = [UIImage imageWithCGImage:outputCGImage];
// Clean up
CGImageRelease(outputCGImage);
CGContextRelease(context);
CGColorSpaceRelease(colorSpace);
return result;
}
@end
Android Implementation Strategies
Kotlin-First Approach
Modern Android native modules should be written in Kotlin for better type safety and null safety:
// BiometricAuth.kt (Android)
class BiometricAuthModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
private val biometricPrompt: BiometricPrompt by lazy {
val executor = ContextCompat.getMainExecutor(currentActivity)
BiometricPrompt(currentActivity as FragmentActivity, executor, authenticationCallback)
}
override fun getName(): String = "BiometricAuth"
@ReactMethod
fun authenticate(promise: Promise) {
try {
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Authenticate")
.setSubtitle("Use your fingerprint or face to authenticate")
.setNegativeButtonText("Cancel")
.build()
biometricPrompt.authenticate(promptInfo)
currentPromise = promise
} catch (e: Exception) {
promise.reject("BIOMETRIC_ERROR", e.message, e)
}
}
@ReactMethod(isBlockingSynchronousMethod = true)
fun isAvailable(): Boolean {
return BiometricManager.from(reactApplicationContext)
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
}
private val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
val response = WritableNativeMap().apply {
putBoolean("success", true)
putDouble("timestamp", System.currentTimeMillis().toDouble())
putString("biometryType", getBiometryType())
}
currentPromise?.resolve(response)
currentPromise = null
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
currentPromise?.reject("AUTH_FAILED", errString.toString())
currentPromise = null
}
}
private fun getBiometryType(): String {
val biometricManager = BiometricManager.from(reactApplicationContext)
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)) {
BiometricManager.BIOMETRIC_SUCCESS -> "Fingerprint"
else -> "Unknown"
}
}
companion object {
private var currentPromise: Promise? = null
}
}
Advanced Android Patterns
// NetworkModule.kt - Advanced networking with OkHttp
class NetworkModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(LoggingInterceptor())
.build()
@ReactMethod
fun downloadFile(url: String, destination: String, promise: Promise) {
val request = Request.Builder()
.url(url)
.build()
okHttpClient.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
promise.reject("DOWNLOAD_FAILED", e.message, e)
}
override fun onResponse(call: Call, response: Response) {
response.body?.let { body ->
try {
val file = File(destination)
file.parentFile?.mkdirs()
body.byteStream().use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
val result = WritableNativeMap().apply {
putString("path", file.absolutePath)
putDouble("size", file.length().toDouble())
}
promise.resolve(result)
} catch (e: Exception) {
promise.reject("SAVE_FAILED", e.message, e)
}
} ?: promise.reject("NO_RESPONSE_BODY", "Response body is null")
}
})
}
}
Testing Native Modules
Unit Testing Strategies
// __tests__/BiometricAuth.test.js
import { NativeModules } from 'react-native';
import BiometricAuth from '../BiometricAuth';
// Mock the native module
jest.mock('react-native', () => ({
NativeModules: {
BiometricAuth: {
authenticate: jest.fn(),
isAvailable: jest.fn(),
},
},
}));
describe('BiometricAuth', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should authenticate successfully', async () => {
const mockResult = {
success: true,
timestamp: 1640995200000,
biometryType: 'FaceID',
};
NativeModules.BiometricAuth.authenticate.mockResolvedValue(mockResult);
const result = await BiometricAuth.authenticate();
expect(result).toEqual(mockResult);
expect(NativeModules.BiometricAuth.authenticate).toHaveBeenCalledTimes(1);
});
it('should handle authentication failure', async () => {
const mockError = new Error('Authentication failed');
NativeModules.BiometricAuth.authenticate.mockRejectedValue(mockError);
await expect(BiometricAuth.authenticate()).rejects.toThrow('Authentication failed');
});
it('should check biometry availability', () => {
NativeModules.BiometricAuth.isAvailable.mockReturnValue(true);
const isAvailable = BiometricAuth.isAvailable();
expect(isAvailable).toBe(true);
expect(NativeModules.BiometricAuth.isAvailable).toHaveBeenCalledTimes(1);
});
});
Integration Testing with Detox
// e2e/biometric.e2e.js
describe('Biometric Authentication', () => {
beforeEach(async () => {
await device.reloadReactNative();
});
it('should authenticate with biometrics', async () => {
await element(by.id('biometric-login-button')).tap();
// Simulate successful biometric authentication
await device.setBiometricEnrollment(true);
await device.matchBiometric();
await expect(element(by.id('dashboard'))).toBeVisible();
});
it('should handle biometric failure', async () => {
await element(by.id('biometric-login-button')).tap();
// Simulate failed biometric authentication
await device.setBiometricEnrollment(true);
await device.unmatchBiometric();
await expect(element(by.text('Authentication failed'))).toBeVisible();
});
});
Performance Optimization Techniques
1. Lazy Loading Native Modules
// LazyNativeModule.js
let _nativeModule = null;
export const getNativeModule = () => {
if (!_nativeModule) {
_nativeModule = require('react-native').NativeModules.ExpensiveModule;
}
return _nativeModule;
};
export const performExpensiveOperation = async (data) => {
const module = getNativeModule();
return await module.processData(data);
};
2. Batch Operations
// BatchOperations.js
export class BatchProcessor {
constructor() {
this.pendingOperations = [];
this.batchTimeout = null;
}
addOperation(operation) {
this.pendingOperations.push(operation);
if (this.batchTimeout) {
clearTimeout(this.batchTimeout);
}
this.batchTimeout = setTimeout(() => {
this.processBatch();
}, 100); // Batch operations within 100ms
}
async processBatch() {
if (this.pendingOperations.length === 0) return;
const operations = [...this.pendingOperations];
this.pendingOperations = [];
try {
const results = await NativeModules.BatchProcessor.processBatch(operations);
operations.forEach((operation, index) => {
operation.resolve(results[index]);
});
} catch (error) {
operations.forEach(operation => {
operation.reject(error);
});
}
}
}
Debugging and Development Tools
Native Module Debugger
// NativeModuleDebugger.js
export const createDebugWrapper = (moduleName, module) => {
if (!__DEV__) return module;
const wrapper = {};
Object.keys(module).forEach(methodName => {
if (typeof module[methodName] === 'function') {
wrapper[methodName] = async (...args) => {
console.log(`[${moduleName}.${methodName}] Called with:`, args);
const startTime = performance.now();
try {
const result = await module[methodName](...args);
const endTime = performance.now();
console.log(`[${moduleName}.${methodName}] Completed in ${endTime - startTime}ms:`, result);
return result;
} catch (error) {
const endTime = performance.now();
console.error(`[${moduleName}.${methodName}] Failed in ${endTime - startTime}ms:`, error);
throw error;
}
};
} else {
wrapper[methodName] = module[methodName];
}
});
return wrapper;
};
Future-Proofing with JSI
Direct JSI Implementation
For maximum performance, you can implement direct JSI modules:
// JSI Implementation (C++)
#include <jsi/jsi.h>
class MathUtilsJSI : public facebook::jsi::HostObject {
public:
facebook::jsi::Value get(facebook::jsi::Runtime& runtime, const facebook::jsi::PropNameID& propName) override {
auto name = propName.utf8(runtime);
if (name == "multiply") {
return facebook::jsi::Function::createFromHostFunction(
runtime,
facebook::jsi::PropNameID::forAscii(runtime, "multiply"),
2,
[](facebook::jsi::Runtime& runtime,
const facebook::jsi::Value& thisValue,
const facebook::jsi::Value* arguments,
size_t count) -> facebook::jsi::Value {
if (count != 2) {
throw facebook::jsi::JSError(runtime, "multiply expects 2 arguments");
}
double a = arguments[0].asNumber();
double b = arguments[1].asNumber();
return facebook::jsi::Value(a * b);
}
);
}
return facebook::jsi::Value::undefined();
}
};
Best Practices and Guidelines
1. Error Handling
Always implement comprehensive error handling:
export const createSafeNativeMethod = (method) => {
return async (...args) => {
try {
return await method(...args);
} catch (error) {
// Log error for debugging
console.error('Native method failed:', error);
// Provide fallback or rethrow with better context
if (error.code === 'PERMISSION_DENIED') {
throw new Error('Permission required. Please enable in settings.');
}
throw error;
}
};
};
2. Type Safety
Use TypeScript interfaces for all native module communications:
// Always define clear interfaces
export interface NativeModuleSpec extends TurboModule {
readonly getConstants: () => Constants;
methodName(param: ParamType): Promise<ReturnType>;
}
3. Documentation
Document your native modules thoroughly:
/**
* Biometric Authentication Module
*
* Provides secure biometric authentication using device capabilities.
* Supports Touch ID, Face ID on iOS and Fingerprint on Android.
*
* @example
* import BiometricAuth from './BiometricAuth';
*
* const result = await BiometricAuth.authenticate();
* if (result.success) {
* // User authenticated successfully
* }
*/
Conclusion
Building high-performance native modules is both an art and a science. The key lessons from my experience:
- Start with TurboModules for new projects
- Optimize for memory efficiency in data-heavy modules
- Implement comprehensive error handling
- Use TypeScript for type safety
- Test thoroughly on both platforms
- Document everything for future maintenance
The React Native ecosystem continues to evolve rapidly. With the New Architecture fully stable and JSI becoming more accessible, we have unprecedented opportunities to build native modules that are both powerful and performant.
Whether you're integrating complex device APIs, optimizing performance-critical operations, or bridging existing native libraries, these patterns and techniques will help you build robust, maintainable native modules that scale with your application's needs.
Have you built any interesting native modules? I'd love to hear about your experiences and challenges in the comments!
