React Native's Bridgeless Architecture
The new architecture removes the asynchronous bridge, unlocking synchronous performance and simpler native integrations.

For years, working with React Native meant internalizing a particular rhythm: JavaScript runs on its own thread, native UI lives on another, and a JSON-based “bridge” carries messages between them. When you tap a button or scroll a list, you can almost feel the hops: JS emits a serialized command, the bridge routes it, and the native side responds. This model brought React Native to millions of developers and apps, but it also baked in certain constraints—async-only communication, serialization overhead, and a class of tricky “bridge bottleneck” bugs.
React Native’s Bridgeless Architecture is the project’s response to those constraints. It’s not just a refactor; it’s a shift in how the JS and native layers interact, moving from an asynchronous message queue to a shared runtime model where the JavaScript engine is hosted directly by the framework. The result is more direct, synchronous communication and a simpler interface for native modules. If you’ve ever wrestled with a custom native module that had to simulate synchronous behavior, or watched performance traces fill up with bridge traffic, bridgeless is the upgrade you’ve been waiting for.
I’ll walk you through what bridgeless is, why it matters now, how it works in practice, where it shines, and where you should still be cautious. I’ll ground this in patterns I’ve used in production apps, and provide concrete code you can reference when you migrate or start a new project.
Context: Where bridgeless fits in the React Native ecosystem today
React Native’s new architecture has been years in the making. It includes several pieces—JSI (JavaScript Interface), TurboModules, and Fabric (the new renderer)—and bridgeless is the mode that ties them together. As of recent releases, bridgeless is available for apps on iOS and Android, with broader stability and adoption across the ecosystem.
In real-world projects, bridgeless matters most for teams that:
- Need snappy interop between JS and native, especially for high-frequency calls (e.g., gesture handling, animations, streaming data).
- Build complex native integrations (BLE, sensors, media, custom view managers) and want a simpler, more direct surface than the old NativeModules API.
- Plan to invest in the new architecture and want to future-proof their app.
If you’re starting a brand-new app today, you can choose to enable bridgeless from day one. If you have an established app, you’ll likely migrate incrementally: update dependencies, test TurboModules replacements for custom native modules, and then enable bridgeless. In either case, your UI components will move to Fabric and your native modules to TurboModules under the hood.
Compared to alternatives:
- Expo: Expo Go and EAS are increasingly new-architecture friendly. Many Expo SDK modules are already TurboModules. If you’re on Expo, your migration path often means updating to SDK versions that support bridgeless and using prebuild or custom dev clients.
- Flutter: Flutter compiles to native and uses its own rendering engine. React Native’s bridgeless is still fundamentally about React and native platform APIs, so you keep the React model and platform semantics. Flutter trades that for a consistent skia-rendered UI across platforms.
- Native (Swift/Kotlin): If your app is primarily native, bridgeless doesn’t change the calculus. React Native bridgeless is for apps that want to stay in React while improving native interop.
Bottom line: Bridgeless is not a “must-have” for every app, but it is the direction React Native is moving. It is well-suited for performance-sensitive integrations, complex native modules, and teams that want to minimize the friction of JS-native communication.
What “bridgeless” actually means: the technical core
Bridgeless changes the way JS and native talk. Instead of a JSON-serialized message queue, the JavaScript runtime is hosted by the framework. This enables:
- Synchronous calls from JS to native via JSI.
- Direct object references across the boundary, avoiding serialization for many operations.
- A cleaner native module API (TurboModules) that leverages JSI.
JSI: The interface that makes it possible
JSI is the foundation. It lets JavaScript hold references to native objects and call methods on them directly. In practice, you rarely write raw JSI code; you rely on TurboModules or framework-level APIs. But understanding JSI helps demystify why bridgeless is faster and more direct.
A TurboModule uses JSI to register native functions and objects with the JS runtime. When JS calls a method on a TurboModule, it’s not posting a JSON message; it’s invoking a native function through JSI. The call is synchronous, and you can pass rich types without serialization overhead.
TurboModules: The new native module API
TurboModules replace NativeModules for new architecture apps. They’re lazy-loaded, typed, and integrated with JSI. A TurboModule is defined in C++ (for the interface) and implemented in platform-native code (Swift/Objective-C on iOS, Kotlin on Android).
In practice, migrating to TurboModules means:
- Defining a spec for your module’s methods and types.
- Implementing the module natively with the spec.
- Loading the module only when used, which improves startup.
Fabric: The new renderer
Fabric is the new UI layer. It’s also built on JSI and supports synchronous rendering and concurrent features. While bridgeless can technically be used with the old renderer, the full benefits come when Fabric is enabled too. Fabric is what allows React 18 features (like concurrent rendering) to coordinate with native UI more efficiently.
Concurrency and React 18
Bridgeless pairs naturally with React 18 features. Because the JS-to-native calls are synchronous, updates like startTransition and automatic batching can integrate more cleanly with native views. This doesn’t magically make everything faster, but it provides better scheduling and fewer surprises when interacting with native UI.
Real-world patterns and code
Below are patterns you’ll encounter in a bridgeless app. I’ve focused on practical examples you can adapt to your own codebase. All code uses modern React Native conventions and is oriented to the new architecture.
Setting up a new bridgeless project
Starting fresh is the easiest way to experience bridgeless. The react-native template now supports the new architecture through flags.
# Create a new app with the new architecture enabled
npx @react-native-community/cli@latest init MyApp --template react-native@latest
# If your CLI supports flags, you can explicitly enable the new architecture
# (Check the current CLI documentation for the exact flag; it may be --new-arch-enabled true)
Your package.json should reference React Native 0.74+ (or whichever version your team uses), and you’ll want to ensure compatible versions of community libraries. Metro bundler configuration remains similar; for TypeScript, enable moduleSuffixes if you need platform-specific extensions.
For iOS, ensure your Podfile sets the right flags for the new architecture. In many setups, this is controlled by an environment variable:
# Terminal environment for iOS pods
export RCT_NEW_ARCH_ENABLED=1
cd ios && pod install
On Android, Gradle properties typically control the flag:
# android/gradle.properties
newArchEnabled=true
For most teams, the key workflow is: update dependencies, enable the flags, run a clean build, and then validate with a smoke test of core features.
Defining and implementing a TurboModule
Let’s say we have a native module for reading/writing a secure token. We’ll define a spec and implement it natively. This is a simplified example focusing on the pattern, not the full boilerplate.
Spec (C++ header, used to define the interface in JSI; in practice, many libs generate this via codegen):
// MySecureStorageSpec.h (conceptual, typically generated by codegen)
#pragma once
#include <jsi/jsi.h>
#include <react/renderer/components/rncore/EventEmitters.h>
namespace facebook {
namespace react {
class JSI_EXPORT MySecureStorageSpec : public jsi::HostObject {
public:
MySecureStorageSpec(const std::string &key);
jsi::Value get(jsi::Runtime &runtime, const jsi::PropNameID &name) override;
// In practice, methods are registered and invoked via JSI
// Codegen helps generate parts of this interface.
};
} // namespace react
} // namespace facebook
Native implementation (Android Kotlin):
// android/app/src/main/java/com/myapp/MySecureStorageModule.kt
package com.myapp
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.bridge.WritableNativeArray
// In bridgeless, this module is registered as a TurboModule
class MySecureStorageModule(reactContext: ReactApplicationContext) : MySecureStorageSpec(reactContext) {
private val context = reactContext
// This method is exposed via JSI when the module is loaded
override fun get(key: String?, promise: Promise) {
try {
val prefs = context.getSharedPreferences("secure", Context.MODE_PRIVATE)
val value = prefs.getString(key, null)
promise.resolve(value)
} catch (e: Exception) {
promise.reject("GET_ERROR", e)
}
}
override fun set(key: String?, value: String?, promise: Promise) {
try {
val prefs = context.getSharedPreferences("secure", Context.MODE_PRIVATE)
with(prefs.edit()) {
putString(key, value)
apply()
}
promise.resolve(true)
} catch (e: Exception) {
promise.reject("SET_ERROR", e)
}
}
override fun remove(key: String?, promise: Promise) {
try {
val prefs = context.getSharedPreferences("secure", Context.MODE_PRIVATE)
with(prefs.edit()) {
remove(key)
apply()
}
promise.resolve(true)
} catch (e: Exception) {
promise.reject("REMOVE_ERROR", e)
}
}
// For TurboModule registration, we typically include a companion object
companion object {
const val NAME = "MySecureStorage"
}
override fun getName(): String = NAME
}
Native implementation (iOS Swift):
// ios/MySecureStorageModule.swift
import Foundation
import React
@objc(MySecureStorageModule)
class MySecureStorageModule: NSObject, MySecureStorageSpec {
@objc static func requiresMainQueueSetup() -> Bool { false }
func get(_ key: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
guard let key = key else {
reject("INVALID_KEY", "Key is nil", nil)
return
}
let value = UserDefaults.standard.string(forKey: key)
resolve(value)
}
func set(_ key: String?, value: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
guard let key = key, let value = value else {
reject("INVALID_INPUT", "Key or value is nil", nil)
return
}
UserDefaults.standard.set(value, forKey: key)
resolve(true)
}
func remove(_ key: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
guard let key = key else {
reject("INVALID_KEY", "Key is nil", nil)
return
}
UserDefaults.standard.removeObject(forKey: key)
resolve(true)
}
}
JS side using the module in bridgeless:
// src/modules/SecureStorage.ts
import { NativeModules } from 'react-native';
const { MySecureStorage } = NativeModules;
export async function readToken(): Promise<string | null> {
try {
return await MySecureStorage.get('auth_token');
} catch (e) {
console.error('Failed to read token', e);
return null;
}
}
export async function saveToken(token: string): Promise<void> {
try {
await MySecureStorage.set('auth_token', token);
} catch (e) {
console.error('Failed to save token', e);
throw e;
}
}
export async function deleteToken(): Promise<void> {
try {
await MySecureStorage.remove('auth_token');
} catch (e) {
console.error('Failed to delete token', e);
throw e;
}
}
Usage in a component:
// src/screens/LoginScreen.tsx
import React, { useState } from 'react';
import { View, TextInput, Button, Text } from 'react-native';
import { readToken, saveToken, deleteToken } from '../modules/SecureStorage';
export function LoginScreen() {
const [token, setToken] = useState('');
async function handleSave() {
await saveToken(token);
const saved = await readToken();
console.log('Saved token read back:', saved);
}
async function handleDelete() {
await deleteToken();
const current = await readToken();
console.log('After delete, token is:', current);
}
return (
<View style={{ padding: 16 }}>
<TextInput
placeholder="Auth token"
value={token}
onChangeText={setToken}
style={{ borderWidth: 1, padding: 8, marginBottom: 12 }}
/>
<Button title="Save" onPress={handleSave} />
<Button title="Delete" onPress={handleDelete} />
</View>
);
}
This is a straightforward pattern: define the module natively, register it as a TurboModule, and call it from JS. In bridgeless, these calls are synchronous under the hood, and you’ll see lower latency compared to the old NativeModules bridge.
Using JSI for a high-frequency sensor stream
For high-frequency data (like a sensor or a media stream), you may want a more direct JSI-based approach. Instead of sending frequent events over a bridge, you can expose a native listener object that JS holds a reference to. This is a simplified version of a pattern we used in a fitness app to avoid event-thrashing.
// android/app/src/main/java/com/myapp/SensorJSIModule.kt
package com.myapp
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.Arguments
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
// A lightweight JSI-exposed object would use a HostObject in C++.
// Here, we use a pattern with a shared event emitter for simplicity.
class SensorJSIModule(reactContext: ReactApplicationContext) {
private val context = reactContext
private var sensorManager: SensorManager? = null
private var listener: SensorEventListener? = null
@ReactMethod
fun startAccel(promise: Promise) {
sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (sensor == null) {
promise.reject("NO_SENSOR", "Accelerometer not available")
return
}
listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
event?.let {
val map = Arguments.createMap()
map.putDouble("x", it.values[0].toDouble())
map.putDouble("y", it.values[1].toDouble())
map.putDouble("z", it.values[2].toDouble())
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("accel", map)
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
sensorManager?.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI)
promise.resolve(true)
}
@ReactMethod
fun stopAccel(promise: Promise) {
listener?.let { sensorManager?.unregisterListener(it) }
promise.resolve(true)
}
}
JS side:
// src/modules/Accel.ts
import { NativeModules, NativeEventEmitter } from 'react-native';
const SensorJSIModule = NativeModules.SensorJSIModule;
const emitter = new NativeEventEmitter(SensorJSIModule);
export function startAccel() {
return SensorJSIModule.startAccel();
}
export function stopAccel() {
return SensorJSIModule.stopAccel();
}
export function onAccel(callback: (data: { x: number; y: number; z: number }) => void) {
const sub = emitter.addListener('accel', callback);
return () => sub.remove();
}
Usage in a component:
// src/screens/AccelScreen.tsx
import React, { useEffect, useState } from 'react';
import { View, Text, Button } from 'react-native';
import { startAccel, stopAccel, onAccel } from '../modules/Accel';
export function AccelScreen() {
const [data, setData] = useState({ x: 0, y: 0, z: 0 });
useEffect(() => {
const unsubscribe = onAccel(setData);
return () => unsubscribe();
}, []);
return (
<View style={{ padding: 16 }}>
<Text>X: {data.x.toFixed(2)}</Text>
<Text>Y: {data.y.toFixed(2)}</Text>
<Text>Z: {data.z.toFixed(2)}</Text>
<Button title="Start" onPress={() => startAccel()} />
<Button title="Stop" onPress={() => stopAccel()} />
</View>
);
}
Note: In a fully JSI-based stream, you would avoid the event emitter and instead have JS call a native function that registers a JS callback, which the native side invokes directly via JSI. That pattern yields the lowest overhead and is where bridgeless really shines, but it requires more native code (a HostObject in C++ and platform-specific implementations). For many teams, the event emitter pattern is sufficient and easier to maintain.
Metro and TypeScript configuration
For TypeScript, use moduleSuffixes to resolve .native.ts and platform-specific files cleanly. Metro config stays largely the same, but for new architecture projects, it’s worth ensuring you’re on a recent Metro version.
// tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"lib": ["es2020"],
"jsx": "react-native",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"moduleSuffixes": [".native", ".ios", ".android", ""]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "android", "ios"]
}
Metro config:
// metro.config.js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const defaultConfig = getDefaultConfig(__dirname);
const config = {
resolver: {
sourceExts: ['js', 'jsx', 'ts', 'tsx', 'json'],
},
transformer: {
babelTransformerPath: require.resolve('react-native-react-babel-transformer'),
},
// If you have a monorepo, add watchFolders accordingly
// watchFolders: [path.resolve(__dirname, '../../packages')],
};
module.exports = mergeConfig(defaultConfig, config);
A simple project structure
A clean structure keeps TurboModules and components organized. This is a minimal layout you can adapt:
MyApp/
├── android/
├── ios/
├── src/
│ ├── components/
│ │ ├── Button.tsx
│ │ └── List.tsx
│ ├── modules/
│ │ ├── SecureStorage.ts
│ │ ├── Accel.ts
│ │ └── MySecureStorageModule (native impl)
│ ├── screens/
│ │ ├── LoginScreen.tsx
│ │ └── AccelScreen.tsx
│ ├── hooks/
│ │ └── useSecureToken.ts
│ ├── utils/
│ │ └── logger.ts
│ └── App.tsx
├── metro.config.js
├── tsconfig.json
├── package.json
└── README.md
In App.tsx, you’ll wire your screens and ensure providers, navigation, and any contexts are set up. Bridgeless doesn’t change the React layer; it improves the JS-native boundary.
Honest evaluation: strengths, weaknesses, and tradeoffs
Strengths
- Synchronous interop: JSI calls feel like local function calls, which reduces latency and serialization overhead.
- Cleaner native APIs: TurboModules provide a stronger contract and lazy loading, helping startup performance and maintainability.
- Better concurrency alignment: Bridgeless pairs well with React 18 features, letting you schedule updates more predictably.
- Future-proof: Bridgeless is the path forward. New features and optimizations will build on it.
Weaknesses and tradeoffs
- Migration cost: Existing apps will need to update libraries, convert custom native modules to TurboModules, and test thoroughly.
- Ecosystem readiness: Not all community libraries are fully new-architecture ready. You’ll need to check compatibility and sometimes fork or patch.
- C++ layer: While you rarely write C++, understanding JSI and codegen adds a learning curve. Debugging native interop can be trickier than pure JS.
- Stability cadence: As with any major change, you should adopt bridgeless on a release cadence that includes thorough QA, especially for complex native modules.
When bridgeless is a good choice
- Your app has frequent JS-native calls (e.g., sensors, media, custom gesture handlers).
- You’re building a new app and want the best performance baseline.
- Your team is willing to invest in library updates and native module migration.
When you might wait or skip
- Your app is simple, with minimal native interop, and you’re on a tight timeline.
- You depend heavily on third-party libraries not yet supporting the new architecture.
- Your team has limited native expertise and relies on a stable, older setup.
Personal experience: lessons from the field
In one of our projects, a health and fitness app, we hit a wall with high-frequency sensor data. The old bridge would occasionally backlog, causing jitter in the UI. Moving to TurboModules (pre-bridgeless at the time) and later enabling bridgeless stabilized the flow. The key wasn’t just raw speed—it was predictability. Synchronous JSI calls meant that reading a sensor value and updating a chart felt immediate, and we avoided the serialization surprises that sometimes creep in with JSON messages.
The learning curve was real. Our biggest mistake was trying to migrate everything at once. We ended up with a mix of working and broken native modules. The fix was a staged approach:
- Audit all custom native modules and categorize by usage frequency.
- Convert the high-impact modules first (sensors, BLE, media).
- Add robust end-to-end tests that cover the JS-native boundary (e.g., reading and writing tokens, streaming sensors).
- Use feature flags to enable bridgeless for beta testers first.
Another surprise: error handling. In the old bridge, errors carried a lot of context automatically. With TurboModules and JSI, you must be disciplined about rejecting promises with clear codes and messages. We created a small utility to standardize errors across native modules, which saved us hours in debugging.
Moments where bridgeless felt especially valuable:
- During a UX pass on a data-heavy screen, we saw smoother scrolling and fewer frames dropped when native views were updated in response to JS state changes.
- Onboarding a new engineer to native module development was easier because the TurboModule contract is explicit and the codegen output helps keep types aligned.
Getting started: workflow and mental models
The mental model
Think of bridgeless as removing the “middleman.” Instead of packaging calls into JSON and sending them over a queue, JS and native can directly reference each other’s objects. Your workflow remains familiar:
- Build UI in React.
- Implement heavy or platform-specific logic in native modules.
- Use TurboModules to expose native logic to JS.
Your tooling stays similar:
- Debugging: Flipper (with limitations), Metro logs, Xcode/Android Studio for native crashes.
- Performance: Use React DevTools Profiler for JS, native profilers for native; pay attention to JSI call frequency and whether you’re unintentionally blocking.
- Testing: Jest for unit tests, Detox or Maestro for E2E; add tests that cover your TurboModule API boundary.
Common pitfalls
- Assuming all libraries work: Always verify new-architecture support. If a library isn’t ready, consider replacing it or maintaining a fork.
- Overusing JSI for simple modules: For many modules, TurboModules with promises are enough. Only reach for pure JSI HostObjects when you need low-latency streams or heavy synchronous operations.
- Ignoring queueing: Even in bridgeless, some operations (like UI updates) should be scheduled correctly. Understand when to use
InteractionManageror React 18 transitions.
Free learning resources
- React Native New Architecture Docs: https://reactnative.dev/docs/new-architecture-intro The official guide is the best starting point for understanding JSI, TurboModules, and Fabric.
- React Native TurboModules Guide: https://reactnative.dev/docs/turbo-modules Practical steps to define and implement TurboModules.
- React 18 in React Native: https://reactnative.dev/docs/react-18 Details on concurrent features and how they integrate with the new architecture.
- Expo New Architecture Support: https://docs.expo.dev/workflow/ios-simulator/ Look for Expo’s documentation on new architecture and custom dev clients; they update frequently.
- Metro Configuration: https://metrobundler.dev/docs/configuration Reference for Metro config, especially useful for TypeScript and monorepos.
- RNNewArchitecture repository/community examples: Search for “react-native new architecture example” on GitHub to see practical migrations.
Conclusion: who should use bridgeless now, and who might wait
Bridgeless is a meaningful upgrade. It reduces latency, simplifies native module APIs, and aligns with React 18 concurrency. For teams building new apps, or apps with heavy JS-native interaction, it’s a compelling choice. The migration is manageable with a staged plan, and the benefits often show up in performance traces and user experience.
If you maintain a stable app with minimal native modules and limited bandwidth, you can wait. There’s no urgent requirement to switch today, and skipping the migration is fine if your app doesn’t benefit from lower latency or synchronous calls. Bridgeless is the future, but it doesn’t have to be the immediate present.
For everyone else: update your dependencies, audit your native modules, start with high-impact pieces, and move towards bridgeless with confidence. The bridge had a good run; removing it unlocks a more direct, predictable, and performant React Native.




