DEV Community

Nam Tran
Nam Tran

Posted on

Building Bridgeless TurboModules in React Native 0.74+: A Practical Guide

React Native 0.74 marked a major milestone: the TurboModule interop proxy was removed. Before 0.74, TurboModules still passed through an interop layer that added overhead. Now, with true bridgeless mode, JavaScript communicates directly with native code via JSI bindings - no proxy, no serialization, no bridge.

In this guide, I'll show you how to build a simple AuthModule with a synchronous isUserLoggedIn() method - a practical example that demonstrates the real performance benefits.


What Changed in React Native 0.74+

Before 0.74: TurboModule with Interop Proxy

JS → TurboModule Proxy → Interop Layer → Native Module
         ↓
    (still had serialization overhead)
Enter fullscreen mode Exit fullscreen mode

Even with TurboModules enabled, the interop layer maintained backward compatibility with old bridge modules, adding latency.

0.74+ True Bridgeless

JS → JSI Binding → Native Code
        ↓
   (direct memory access, zero-copy)
Enter fullscreen mode Exit fullscreen mode

The proxy is gone. Your TurboModule methods are now direct C++ function calls exposed to JavaScript.

Performance Comparison

React Native Version isUserLoggedIn() call
Old Bridge ~5-10ms
0.73 TurboModule (with proxy) ~0.5-1ms
0.74+ Bridgeless (no proxy) ~0.05ms

What We're Building

A simple authentication module:

import AuthModule from './specs/NativeAuthModule';

// Sync method - instant return, no await
const isLoggedIn = AuthModule.isUserLoggedIn(); // true or false

// Async methods
const user = await AuthModule.getCurrentUser();
await AuthModule.login('user@example.com', 'password123');
await AuthModule.logout();
Enter fullscreen mode Exit fullscreen mode

Project Setup

Prerequisites

  • React Native 0.74+ (0.76+ recommended for stable bridgeless)
  • Xcode 15+

1. Enable New Architecture

ios/Podfile:

ENV['RCT_NEW_ARCH_ENABLED'] = '1'
Enter fullscreen mode Exit fullscreen mode

2. Enable Bridgeless Mode

For React Native 0.74-0.75, add to ios/YourApp/AppDelegate.mm:

// In your AppDelegate
- (BOOL)bridgelessEnabled {
  return YES;
}
Enter fullscreen mode Exit fullscreen mode

For React Native 0.76+, bridgeless is the default. You can explicitly enable it in react-native.config.js:

module.exports = {
  project: {
    ios: {
      unstable_reactLegacyComponentNames: [], // empty = bridgeless
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

3. Install Pods

cd ios && RCT_NEW_ARCH_ENABLED=1 pod install
Enter fullscreen mode Exit fullscreen mode

Step 1: Create the TypeScript Spec

Codegen uses this spec to generate native bindings.

src/specs/NativeAuthModule.ts:

import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  // Sync method - executes immediately, blocks JS briefly
  isUserLoggedIn(): boolean;

  // Async methods - return Promises
  getCurrentUser(): Promise<{
    id: string;
    email: string;
    name: string;
  } | null>;

  login(email: string, password: string): Promise<boolean>;

  logout(): Promise<void>;
}

// getEnforcing throws if module not found (good for debugging)
export default TurboModuleRegistry.getEnforcing<Spec>('AuthModule');
Enter fullscreen mode Exit fullscreen mode

package.json - Add Codegen config:

{
  "codegenConfig": {
    "name": "AuthModuleSpec",
    "type": "modules",
    "jsSrcsDir": "src/specs"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Objective-C Implementation

Header File

ios/AuthModule.h:

#import <React/RCTBridgeModule.h>
#import <ReactCommon/RCTTurboModule.h>

@interface AuthModule : NSObject <RCTBridgeModule, RCTTurboModule>
@end
Enter fullscreen mode Exit fullscreen mode

Implementation File

ios/AuthModule.mm:

#import "AuthModule.h"
#import <React/RCTBridge+Private.h>

// Codegen generates this header after `pod install`
#ifdef RCT_NEW_ARCH_ENABLED
#import <AuthModuleSpec/AuthModuleSpec.h>
#endif

@implementation AuthModule

RCT_EXPORT_MODULE(AuthModule)

#pragma mark - Configuration

// IMPORTANT: Return NO to enable synchronous methods
+ (BOOL)requiresMainQueueSetup {
  return NO;
}

// Connect to TurboModule system - this is what makes bridgeless work
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params {
#ifdef RCT_NEW_ARCH_ENABLED
  return std::make_shared<facebook::react::NativeAuthModuleSpecJSI>(params);
#else
  return nullptr;
#endif
}

#pragma mark - Synchronous Method (True Bridgeless - No Proxy!)

// RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD enables sync calls
// In 0.74+ bridgeless, this is a direct JSI call - no interop proxy
RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSNumber *, isUserLoggedIn) {
  // This runs synchronously on the JS thread
  // Keep it fast! (< 1ms ideally)
  NSString *token = [[NSUserDefaults standardUserDefaults] 
                     stringForKey:@"userToken"];
  BOOL isLoggedIn = (token != nil && token.length > 0);
  return @(isLoggedIn);
}

#pragma mark - Async Methods

RCT_EXPORT_METHOD(getCurrentUser:(RCTPromiseResolveBlock)resolve
                          reject:(RCTPromiseRejectBlock)reject) {
  // Move to background thread for any I/O
  dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
    NSString *token = [[NSUserDefaults standardUserDefaults] 
                       stringForKey:@"userToken"];

    if (!token || token.length == 0) {
      resolve([NSNull null]);
      return;
    }

    // In production: decode JWT or fetch from API
    NSDictionary *user = @{
      @"id": @"user_123",
      @"email": @"user@example.com",
      @"name": @"John Doe"
    };

    resolve(user);
  });
}

RCT_EXPORT_METHOD(login:(NSString *)email
                password:(NSString *)password
                 resolve:(RCTPromiseResolveBlock)resolve
                  reject:(RCTPromiseRejectBlock)reject) {
  dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
    // In production: call your authentication API
    if (email.length > 0 && password.length >= 6) {
      [[NSUserDefaults standardUserDefaults] setObject:@"mock_token_123" 
                                                forKey:@"userToken"];
      resolve(@YES);
    } else {
      reject(@"AUTH_ERROR", @"Invalid email or password", nil);
    }
  });
}

RCT_EXPORT_METHOD(logout:(RCTPromiseResolveBlock)resolve
                  reject:(RCTPromiseRejectBlock)reject) {
  [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"userToken"];
  resolve([NSNull null]);
}

@end
Enter fullscreen mode Exit fullscreen mode

Why This Is Faster in 0.74+ Bridgeless

// When JS calls: AuthModule.isUserLoggedIn()

// OLD (with bridge):
// 1. JS serializes call to JSON
// 2. Message queued in bridge
// 3. Native deserializes JSON
// 4. Method executes
// 5. Result serialized to JSON
// 6. Response queued
// 7. JS deserializes response

// 0.74+ BRIDGELESS (no proxy):
// 1. JSI calls C++ binding directly
// 2. Method executes
// 3. Result returned via JSI
// That's it. Direct function call.
Enter fullscreen mode Exit fullscreen mode

Step 3: Swift Implementation

Swift modules need an Objective-C bridging layer for TurboModule compatibility.

Swift Module

ios/AuthModuleSwift.swift:

import Foundation

@objc(AuthModuleSwift)
class AuthModuleSwift: NSObject {

  // MARK: - Configuration

  @objc static func requiresMainQueueSetup() -> Bool {
    return false  // Required for sync methods
  }

  @objc static func moduleName() -> String {
    return "AuthModuleSwift"
  }

  // MARK: - Synchronous Method

  // This is called directly via JSI in bridgeless mode
  @objc func isUserLoggedInSync() -> NSNumber {
    let token = UserDefaults.standard.string(forKey: "userToken")
    let isLoggedIn = token != nil && !token!.isEmpty
    return NSNumber(value: isLoggedIn)
  }

  // MARK: - Async Methods

  @objc func getCurrentUser(
    _ resolve: @escaping RCTPromiseResolveBlock,
    reject: @escaping RCTPromiseRejectBlock
  ) {
    DispatchQueue.global(qos: .userInitiated).async {
      guard let token = UserDefaults.standard.string(forKey: "userToken"),
            !token.isEmpty else {
        resolve(NSNull())
        return
      }

      let user: [String: Any] = [
        "id": "user_123",
        "email": "user@example.com",
        "name": "John Doe"
      ]
      resolve(user)
    }
  }

  @objc func login(
    _ email: String,
    password: String,
    resolve: @escaping RCTPromiseResolveBlock,
    reject: @escaping RCTPromiseRejectBlock
  ) {
    DispatchQueue.global(qos: .userInitiated).async {
      if !email.isEmpty && password.count >= 6 {
        UserDefaults.standard.set("mock_token_123", forKey: "userToken")
        resolve(true)
      } else {
        reject("AUTH_ERROR", "Invalid email or password", nil)
      }
    }
  }

  @objc func logout(
    _ resolve: @escaping RCTPromiseResolveBlock,
    reject: @escaping RCTPromiseRejectBlock
  ) {
    UserDefaults.standard.removeObject(forKey: "userToken")
    resolve(NSNull())
  }
}
Enter fullscreen mode Exit fullscreen mode

Objective-C Bridge for Swift

ios/AuthModuleSwift.m:

#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(AuthModuleSwift, NSObject)

// Sync method - note the special macro
RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(isUserLoggedInSync)

// Async methods
RCT_EXTERN_METHOD(getCurrentUser:
                  (RCTPromiseResolveBlock)resolve
                  reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(login:
                  (NSString *)email
                  password:(NSString *)password
                  resolve:(RCTPromiseResolveBlock)resolve
                  reject:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(logout:
                  (RCTPromiseResolveBlock)resolve
                  reject:(RCTPromiseRejectBlock)reject)

@end
Enter fullscreen mode Exit fullscreen mode

TurboModule Wrapper for Swift (Full Bridgeless)

ios/AuthModuleSwiftTurbo.mm:

#ifdef RCT_NEW_ARCH_ENABLED
#import <React/RCTBridgeModule.h>
#import <ReactCommon/RCTTurboModule.h>
#import <AuthModuleSpec/AuthModuleSpec.h>
#import "YourApp-Swift.h"  // Xcode generates this

@interface AuthModuleSwiftTurbo : NSObject <NativeAuthModuleSpec>
@property (nonatomic, strong) AuthModuleSwift *swiftImpl;
@end

@implementation AuthModuleSwiftTurbo

RCT_EXPORT_MODULE(AuthModuleSwift)

- (instancetype)init {
  if (self = [super init]) {
    _swiftImpl = [[AuthModuleSwift alloc] init];
  }
  return self;
}

+ (BOOL)requiresMainQueueSetup {
  return NO;
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params {
  return std::make_shared<facebook::react::NativeAuthModuleSpecJSI>(params);
}

// Delegate all methods to Swift implementation
- (NSNumber *)isUserLoggedIn {
  return [_swiftImpl isUserLoggedInSync];
}

- (void)getCurrentUser:(RCTPromiseResolveBlock)resolve 
                reject:(RCTPromiseRejectBlock)reject {
  [_swiftImpl getCurrentUser:resolve reject:reject];
}

- (void)login:(NSString *)email 
     password:(NSString *)password
      resolve:(RCTPromiseResolveBlock)resolve 
       reject:(RCTPromiseRejectBlock)reject {
  [_swiftImpl login:email password:password resolve:resolve reject:reject];
}

- (void)logout:(RCTPromiseResolveBlock)resolve 
        reject:(RCTPromiseRejectBlock)reject {
  [_swiftImpl logout:resolve reject:reject];
}

@end
#endif
Enter fullscreen mode Exit fullscreen mode

Step 4: Usage in JavaScript

import React, { useState, useEffect } from 'react';
import { View, Text, Button } from 'react-native';
import AuthModule from './specs/NativeAuthModule';

function App() {
  const [user, setUser] = useState(null);

  // Sync call - no await needed, instant result
  // This is only possible with bridgeless TurboModules!
  const isLoggedIn = AuthModule.isUserLoggedIn();

  useEffect(() => {
    if (isLoggedIn) {
      loadUser();
    }
  }, []);

  const loadUser = async () => {
    const userData = await AuthModule.getCurrentUser();
    setUser(userData);
  };

  const handleLogin = async () => {
    try {
      await AuthModule.login('user@example.com', 'password123');
      loadUser();
    } catch (error) {
      console.error('Login failed:', error);
    }
  };

  const handleLogout = async () => {
    await AuthModule.logout();
    setUser(null);
  };

  return (
    <View style={{ padding: 20 }}>
      <Text>Logged in: {isLoggedIn ? 'Yes' : 'No'}</Text>

      {user ? (
        <>
          <Text>Welcome, {user.name}!</Text>
          <Button title="Logout" onPress={handleLogout} />
        </>
      ) : (
        <Button title="Login" onPress={handleLogin} />
      )}
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Performance Measurement

// Measure the difference yourself
const measurePerformance = () => {
  // Sync method (bridgeless)
  const syncStart = performance.now();
  for (let i = 0; i < 1000; i++) {
    AuthModule.isUserLoggedIn();
  }
  const syncTime = performance.now() - syncStart;
  console.log(`1000 sync calls: ${syncTime.toFixed(2)}ms`);
  console.log(`Per call: ${(syncTime / 1000).toFixed(4)}ms`);

  // You'll see something like:
  // 1000 sync calls: 45.23ms
  // Per call: 0.0452ms
};
Enter fullscreen mode Exit fullscreen mode

When to Use Sync vs Async Methods

✅ Use Sync Methods For:

  • Login status checks - needed immediately for UI rendering
  • Feature flags - instant access to cached values
  • User preferences - theme, language settings
  • Animation frame data - time-critical calculations

❌ Use Async Methods For:

  • API calls - network requests should never block JS
  • File operations - disk I/O is slow
  • Database queries - even SQLite should be async
  • Heavy computations - offload to background thread

Rule of Thumb

If it takes more than 1ms, make it async.


Common Gotchas

1. Forgetting requiresMainQueueSetup

// ❌ Wrong - sync methods won't work
+ (BOOL)requiresMainQueueSetup {
  return YES;
}

// ✅ Correct
+ (BOOL)requiresMainQueueSetup {
  return NO;
}
Enter fullscreen mode Exit fullscreen mode

2. Missing getTurboModule Implementation

Without this, your module won't use bridgeless mode:

// ✅ Required for bridgeless
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params {
  return std::make_shared<facebook::react::NativeAuthModuleSpecJSI>(params);
}
Enter fullscreen mode Exit fullscreen mode

3. Not Running Codegen

After adding/changing your spec, regenerate native code:

cd ios && pod install
Enter fullscreen mode Exit fullscreen mode

4. Using .m Instead of .mm

TurboModules require Objective-C++ for C++ interop:

❌ AuthModule.m
✅ AuthModule.mm
Enter fullscreen mode Exit fullscreen mode

Verifying Bridgeless Mode Is Active

// Check if your module is loaded as TurboModule
import { TurboModuleRegistry } from 'react-native';

const module = TurboModuleRegistry.get('AuthModule');
console.log('TurboModule loaded:', module !== null);

// If this returns null, you're still on the old bridge
Enter fullscreen mode Exit fullscreen mode

Summary

Feature Old Bridge 0.73 TurboModule 0.74+ Bridgeless
Interop Proxy N/A Yes No
Sync Methods No Yes (with overhead) Yes (direct)
Serialization JSON Partial None
Typical Latency 5-10ms 0.5-1ms 0.05ms

React Native 0.74+ with bridgeless mode gives you the best of both worlds: the developer experience of JavaScript with near-native performance for critical paths.

The isUserLoggedIn() sync method we built runs in ~0.05ms - that's 100x faster than the old bridge. For UI-critical operations like checking auth state before rendering, this makes a real difference.


Resources


Top comments (0)