Navigation: ← Data Persistence | New Architecture →
React Native's power lies in accessing native platform capabilities while writing most of your app in JavaScript/TypeScript. Understanding the bridge architecture, JSI, and how to write native modules is essential for senior React Native roles where you need to integrate SDKs, optimize performance, or access platform APIs not available in JS.
This guide covers the bridge-to-JSI evolution, native module development on iOS and Android, event communication, and decision criteria for going native.
The original React Native architecture uses an asynchronous, serialized bridge between JavaScript and native code:
JavaScript Thread ←→ Bridge (JSON) ←→ Native Modules Thread
←→ UI Thread (iOS main / Android UI)
How it works:
- JS calls a native module method
- Arguments are JSON-serialized and queued as a message
- Message crosses the bridge asynchronously
- Native module executes on the native modules thread
- Result is serialized back and sent to JS thread
- Promise resolves in JavaScript
Problems:
- Asynchronous only — No synchronous native calls
- Serialization overhead — JSON encode/decode for every call
- Batching delays — Messages queued and processed in batches (~16ms)
- No direct memory access — Cannot share objects between JS and native
- Single-threaded bottleneck — All native module calls share one queue
JSI is a lightweight C++ API that allows JavaScript to hold references to native objects and invoke methods directly without serialization:
JavaScript ←→ JSI (C++ direct calls) ←→ Native Code
Benefits:
- Synchronous calls — Read a value without async overhead
- No serialization — Direct memory access, shared objects
- Smaller overhead — No message queue, no JSON
- Foundation for New Architecture — Fabric and TurboModules built on JSI
JSI is engine-agnostic — works with Hermes, JavaScriptCore, and V8.
Native modules expose platform functionality to JavaScript. Two approaches:
Legacy Native Modules (Bridge):
- iOS: Objective-C/Swift classes conforming to
RCTBridgeModule - Android: Java/Kotlin classes extending
ReactContextBaseJavaModule
TurboModules (New Architecture):
- Spec defined in TypeScript/Flow with Codegen
- C++ JSI bindings generated automatically
- Lazy-loaded, type-safe, synchronous-capable
Native-to-JS communication uses events:
- Legacy:
RCTEventEmitter(iOS) /DeviceEventManagerModule(Android) - TurboModules:
EventEmitterwith typed events via Codegen - JS side:
NativeEventEmitterto subscribe
Use events for: sensor data, Bluetooth callbacks, download progress, keyboard events.
Go native when:
- No existing library covers the use case
- Performance-critical operations (image processing, encryption, ML inference)
- Platform-specific APIs (HealthKit, ARKit, Android Auto)
- Third-party SDK integration (payment, analytics, maps)
- Background processing requirements
Stay in JavaScript when:
- Existing library meets requirements
- UI-only features
- Business logic and data transformation
- Rapid prototyping
// specs/NativeBattery.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
getBatteryLevel(): Promise<number>;
isCharging(): boolean;
startMonitoring(): void;
stopMonitoring(): void;
addListener(eventType: string): void;
removeListeners(count: number): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>('BatteryModule');// BatteryModule.h
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
@interface BatteryModule : RCTEventEmitter <RCTBridgeModule>
@end
// BatteryModule.m
#import "BatteryModule.h"
#import <UIKit/UIKit.h>
@implementation BatteryModule
RCT_EXPORT_MODULE(BatteryModule);
+ (BOOL)requiresMainQueueSetup {
return NO;
}
- (NSArray<NSString *> *)supportedEvents {
return @[@"BatteryLevelChanged"];
}
RCT_EXPORT_METHOD(getBatteryLevel:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
[UIDevice currentDevice].batteryMonitoringEnabled = YES;
float level = [UIDevice currentDevice].batteryLevel;
if (level < 0) {
reject(@"UNAVAILABLE", @"Battery level unavailable", nil);
} else {
resolve(@(level * 100));
}
}
RCT_EXPORT_METHOD(startMonitoring) {
[UIDevice currentDevice].batteryMonitoringEnabled = YES;
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(batteryLevelChanged:)
name:UIDeviceBatteryLevelDidChangeNotification
object:nil];
}
- (void)batteryLevelChanged:(NSNotification *)notification {
float level = [UIDevice currentDevice].batteryLevel;
[self sendEventWithName:@"BatteryLevelChanged"
body:@{@"level": @(level * 100)}];
}
@end// BatteryModule.kt
package com.myapp.modules
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
class BatteryModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "BatteryModule"
private var receiver: BroadcastReceiver? = null
@ReactMethod
fun getBatteryLevel(promise: Promise) {
val batteryManager = reactContext.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
val level = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
promise.resolve(level)
}
@ReactMethod
fun startMonitoring() {
receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
val percentage = (level * 100 / scale.toFloat()).toInt()
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("BatteryLevelChanged", Arguments.createMap().apply {
putInt("level", percentage)
})
}
}
reactContext.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
}
@ReactMethod
fun stopMonitoring() {
receiver?.let { reactContext.unregisterReceiver(it) }
receiver = null
}
}
// BatteryPackage.kt
class BatteryPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(BatteryModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}import { NativeModules, NativeEventEmitter, Platform } from 'react-native';
const { BatteryModule } = NativeModules;
interface BatteryEvent {
level: number;
}
class BatteryService {
private emitter: NativeEventEmitter;
private subscription: ReturnType<NativeEventEmitter['addListener']> | null = null;
constructor() {
this.emitter = new NativeEventEmitter(BatteryModule);
}
async getLevel(): Promise<number> {
return BatteryModule.getBatteryLevel();
}
startMonitoring(onLevelChange: (level: number) => void): void {
BatteryModule.startMonitoring();
this.subscription = this.emitter.addListener(
'BatteryLevelChanged',
(event: BatteryEvent) => onLevelChange(event.level),
);
}
stopMonitoring(): void {
this.subscription?.remove();
this.subscription = null;
BatteryModule.stopMonitoring();
}
}
export const batteryService = new BatteryService();import { useState, useEffect } from 'react';
import { batteryService } from '../services/BatteryService';
export function useBatteryLevel(): { level: number | null; isLoading: boolean } {
const [level, setLevel] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
batteryService.getLevel()
.then(setLevel)
.catch(console.error)
.finally(() => setIsLoading(false));
batteryService.startMonitoring(setLevel);
return () => batteryService.stopMonitoring();
}, []);
return { level, isLoading };
}Answer:
The old bridge is an asynchronous message queue between the JavaScript thread and native threads:
Architecture:
JS Thread ──→ Serialize to JSON ──→ Message Queue ──→ Native Module Thread
JS Thread ←── Deserialize JSON ←── Message Queue ←── Native Module Thread
UI Thread ←── Separate queue ──→ Native View updates
Communication flow:
- JS calls
NativeModules.MyModule.doSomething(arg) - Call is serialized to JSON:
{ module: "MyModule", method: "doSomething", args: [arg] } - Message queued and batched (processed every ~16ms frame)
- Native side deserializes and executes on Native Module thread
- Result serialized back and queued to JS thread
- Promise resolves
Key limitations:
| Limitation | Impact |
|---|---|
| Async-only | Cannot synchronously read native values |
| JSON serialization | Overhead on every call, no binary data |
| Single queue | All modules share one pipeline — head-of-line blocking |
| No type safety | Runtime errors from typos in method names |
| Eager loading | All native modules loaded at startup |
| Memory isolation | Cannot share object references |
These limitations motivated the New Architecture (JSI, Fabric, TurboModules).
Answer:
JSI (JavaScript Interface) is a C++ layer that exposes native objects directly to JavaScript:
Old Bridge:
// Async, serialized, queued
const level = await NativeModules.Battery.getLevel();JSI:
// Sync, direct C++ call, no serialization
const level = global.__BatteryModule.getLevel();| Aspect | Old Bridge | JSI |
|---|---|---|
| Call type | Async only | Sync and async |
| Data transfer | JSON serialize/deserialize | Direct memory access |
| Object sharing | Not possible | Shared C++ objects |
| Startup | All modules loaded | Lazy loading (TurboModules) |
| Type safety | Runtime | Compile-time (Codegen) |
| Engine | Bridge-specific | Engine-agnostic (Hermes, JSC, V8) |
JSI enables:
- Fabric — Synchronous layout and rendering
- TurboModules — Lazy-loaded, typed native modules
- Shared ownership — C++ objects accessible from both JS and native
- Host objects — Native objects appear as JS objects
JSI is the foundation, not the feature itself. Fabric and TurboModules are built on top of JSI.
Answer:
Step 1 — Define the JS interface:
// For TurboModules: TypeScript spec with Codegen
// For legacy: Document the API
interface BatteryModuleInterface {
getBatteryLevel(): Promise<number>;
startMonitoring(): void;
}Step 2 — iOS implementation:
// Conform to RCTBridgeModule
@implementation BatteryModule
RCT_EXPORT_MODULE(); // Exposes to JS as "BatteryModule"
RCT_EXPORT_METHOD(getBatteryLevel:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) { ... }
@endStep 3 — Android implementation:
class BatteryModule(context: ReactApplicationContext) :
ReactContextBaseJavaModule(context) {
override fun getName() = "BatteryModule"
@ReactMethod
fun getBatteryLevel(promise: Promise) { ... }
}Step 4 — Register the module:
- iOS: Create a podspec or add to bridging header
- Android: Create a
ReactPackageand add toMainApplication
Step 5 — Use from JavaScript:
import { NativeModules } from 'react-native';
const { BatteryModule } = NativeModules;For TurboModules (New Architecture):
- Write TypeScript spec in
specs/NativeModule.ts - Run Codegen — generates C++ JSI bindings + native stubs
- Implement generated native abstract classes
- Register in
MainApplication/ AppDelegate
Answer:
Native-to-JS communication uses the Event Emitter pattern:
iOS — Extend RCTEventEmitter:
@interface MyModule : RCTEventEmitter <RCTBridgeModule>
@end
@implementation MyModule
- (NSArray<NSString *> *)supportedEvents {
return @[@"onProgress", @"onComplete"];
}
- (void)sendProgress:(double)progress {
[self sendEventWithName:@"onProgress" body:@{@"progress": @(progress)}];
}
@endAndroid — Use DeviceEventManagerModule:
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("onProgress", Arguments.createMap().apply {
putDouble("progress", progress)
})JavaScript — Subscribe with NativeEventEmitter:
import { NativeEventEmitter, NativeModules } from 'react-native';
const emitter = new NativeEventEmitter(NativeModules.MyModule);
const subscription = emitter.addListener('onProgress', (event) => {
console.log(`Progress: ${event.progress}%`);
});
// Cleanup
subscription.remove();Important rules:
- Always remove listeners on unmount to prevent memory leaks
- Events are fire-and-forget — no acknowledgment from JS
- For request-response patterns, use Promises (JS → native) instead
- TurboModules require
addListener/removeListenersmethods in spec for Codegen
Answer:
Use an existing library when:
- Popular, maintained library exists (e.g.,
react-native-camera,react-native-biometrics) - Library covers 80%+ of your requirements
- Community support and documentation available
- Library supports New Architecture (Fabric/TurboModules)
Write a custom native module when:
- No library exists for the specific SDK/API
- Existing library is unmaintained or incompatible with your RN version
- Performance requirements exceed what JS libraries provide
- Proprietary/third-party SDK with no React Native wrapper
- Need fine-grained control over native behavior
Decision matrix:
| Factor | Use Library | Write Native |
|---|---|---|
| SDK availability | Wrapper exists | No wrapper |
| Performance needs | Adequate | Critical (ML, video, crypto) |
| Maintenance capacity | Low | Team has native devs |
| Customization | Standard features | Highly custom |
| Timeline | Fast delivery | Long-term investment |
Before writing native code, check:
- npm registry for existing modules
- Expo modules (if using Expo)
- Community modules on GitHub
- Whether a Web API alternative exists (WebView bridge)
Answer:
1. Threading violations:
// Bad — updating UI from background thread
@ReactMethod
fun fetchData(promise: Promise) {
Thread {
val result = heavyComputation()
updateUI(result) // CRASH — not on main thread
promise.resolve(result)
}.start()
}Always dispatch UI work to the main thread.
2. Memory leaks from event listeners:
// Bad — never removed
useEffect(() => {
emitter.addListener('event', handler);
// Missing cleanup!
}, []);3. Blocking the JS thread with sync bridge calls: Legacy bridge calls are async, but if you make many rapid calls, the queue backs up and UI stutters.
4. Not handling app lifecycle: Native modules holding resources (Bluetooth, GPS, camera) must clean up on app background/destroy.
5. Missing null checks on Android:
// Bad — activity can be null
reactContext.currentActivity!!.startActivity(intent)
// Good
reactContext.currentActivity?.startActivity(intent)
?: promise.reject("NO_ACTIVITY", "Activity is null")6. Inconsistent module names across platforms:
iOS RCT_EXPORT_MODULE(BatteryModule) must match Android getName() = "BatteryModule".
7. Not using Promises correctly: Always resolve OR reject — never leave promises hanging.
8. Forgetting New Architecture compatibility: Test with both old and new architecture enabled during migration.
Answer:
iOS debugging:
- Xcode debugger — Set breakpoints in
.m/.swiftfiles, run from Xcode - NSLog / os_log — Native logging visible in Xcode console
- React Native DevTools — View native module registration
- Flipper — Inspect native layout, network, databases
RCTLogInfo(@"Battery level: %f", level); // Visible in Metro + XcodeAndroid debugging:
- Android Studio debugger — Breakpoints in
.kt/.javafiles - Logcat — Filter by tag:
adb logcat -s ReactNativeJS:B BatteryModule:D - Flipper — Native layout inspector, network, shared preferences
Log.d("BatteryModule", "Level: $level")JavaScript side debugging:
console.log('Available modules:', Object.keys(NativeModules));
// Verify module is registered: should include 'BatteryModule'Common debugging steps:
- Verify module appears in
NativeModulesobject - Check Metro bundler for import errors
- Rebuild native app after native code changes (
npx react-native run-ios) - Check platform-specific logs (Xcode console / Logcat)
- Use
try/catchon JS side to capture native rejections - Test on both platforms independently — implementations differ
Hot reload does NOT apply to native code — always rebuild after native changes.
| Practice | Reason |
|---|---|
| Prefer TurboModules for new modules | Type-safe, lazy-loaded, JSI-powered |
| Always clean up event listeners | Prevent memory leaks |
| Use Promises for JS → native calls | Proper async error handling |
| Use events for native → JS streaming | Sensor data, progress updates |
| Dispatch UI work to main thread | Prevent crashes on both platforms |
| Match module names across iOS/Android | Consistent JS API |
| Test with New Architecture enabled | Future-proof your modules |
| Rebuild after native changes | Hot reload doesn't apply to native code |
Navigation: ← Data Persistence | New Architecture →