How do you write a native module bridge in React Native, and what are the performance implications?
TurboModules replaced async, JSON-serialized bridge calls with direct JSI bindings — faster, but only if you still respect thread boundaries.
LEARN BY DOINGPut your intuition to work3 steps
How do you write a native module bridge in React Native, and what are the performance implications?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Why native modules exist at all
React Native gives you a huge surface of cross-platform APIs out of the box, but sooner or later a product needs something the JS layer can't do alone: Bluetooth LE, a proprietary SDK with no RN wrapper, a hardware sensor, or a persistence mechanism you want backed directly by UserDefaults/SharedPreferences rather than AsyncStorage. A native module is the escape hatch — a piece of Swift/Objective-C or Kotlin/Java code exposed to JavaScript as if it were a normal import.
#Old bridge vs. TurboModules
Before the New Architecture, every native module call was serialized to JSON, pushed onto a message queue, and processed asynchronously on both sides — even for something as simple as reading a boolean. This had two costs: the serialization/deserialization itself, and the fact that all modules were eagerly instantiated at app startup whether or not they were ever used, which showed up directly in cold-start time.
TurboModules fix both problems. A module is described by a Codegen spec — a plain TypeScript interface — from which React Native generates the native interface code at build time, enforcing type safety across the JS/native boundary before you ever run the app. At runtime, JSI (JavaScript Interface) exposes the native module to JS as a HostObject: JS can call its methods mostly synchronously through direct C++ bindings, without going through JSON or the old async queue. Modules are also lazily instantiated — created the first time JS actually references them, not at launch — which is a direct, measurable startup-time win on apps with many native modules.
#Defining the spec
Every TurboModule starts with a TypeScript contract that Codegen reads to generate the platform glue:
// specs/NativeLocalStorage.tsimport type { TurboModule } from "react-native";import { TurboModuleRegistry } from "react-native";export interface Spec extends TurboModule {// Synchronous — only safe for fast, non-blocking work.getItem(key: string): string | null;setItem(key: string, value: string): void;// Asynchronous — offloads real work to a background queue.multiGet(keys: Array<string>): Promise<Object>;}export default TurboModuleRegistry.getEnforcing<Spec>("NativeLocalStorage");
#Implementing it on iOS (Swift)
@objc(NativeLocalStorage)class NativeLocalStorage: NSObject {private let defaults = UserDefaults.standard@objc func getItem(_ key: String) -> String? {return defaults.string(forKey: key)}@objc func setItem(_ key: String, value: String) {defaults.set(value, forKey: key)}@objc(multiGet:resolver:rejecter:)func multiGet(_ keys: [String],resolver: @escaping RCTPromiseResolveBlock,rejecter: @escaping RCTPromiseRejectBlock) {// Real work happens off the main thread even though JSI supports sync calls.DispatchQueue.global(qos: .userInitiated).async {var result: [String: String?] = [:]for key in keys {result[key] = self.defaults.string(forKey: key)}DispatchQueue.main.async {resolver(result)}}}}
#Implementing it on Android (Kotlin)
class NativeLocalStorageModule(reactContext: ReactApplicationContext) :NativeLocalStorageSpec(reactContext) {private val prefs = reactContext.getSharedPreferences("app_storage", Context.MODE_PRIVATE)override fun getItem(key: String): String? = prefs.getString(key, null)override fun setItem(key: String, value: String) {prefs.edit().putString(key, value).apply()}override fun multiGet(keys: ReadableArray, promise: Promise) {// Offload to a worker thread — SharedPreferences reads are cheap, but// this is the pattern for anything heavier (disk I/O, decoding, crypto).Thread {val result = Arguments.createMap()for (i in 0 until keys.size()) {val key = keys.getString(i)result.putString(key, prefs.getString(key, null))}promise.resolve(result)}.start()}override fun getName() = "NativeLocalStorage"}
#The performance implications that actually matter
Synchronous doesn't mean free. JSI lets you call a native method and get a return value back without a promise round trip, but that call is still executing real native code on whatever thread invoked it. A synchronous JSI method that does meaningful work (parsing a large file, running a cryptographic hash) blocks that thread for its full duration — call it from the JS thread and you've frozen JS-driven UI updates until it returns. The fix is the same as it always was: keep synchronous methods trivially fast, and push real work behind a Promise/callback that hops to a background thread, exactly as multiGet does above.
Lazy loading changes what "adding a module" costs. Under the old bridge, every registered native module was instantiated at launch, so a codebase with 40 native modules paid that cost on every cold start regardless of which screens the user actually visited. TurboModules instantiate on first use, so adding a rarely-used module (say, a PDF export feature) no longer taxes every app launch — it only costs something the first time a user reaches the screen that needs it.
Codegen catches contract drift at build time, not in production. Before Codegen, a mismatched method signature between the JS spec and the native implementation was a runtime crash or, worse, a silent wrong-type bug. Now it's a build failure, which is a real (if less glamorous) performance win: it removes an entire class of production incidents caused by drift between platforms.
HostObjects need explicit lifetime discipline. Under the old bridge, everything crossing the boundary was a JSON value, copied and garbage-collected normally. A HostObject exposed via JSI is a real C++ object with retain/release semantics; holding a stale reference to one, or exposing native memory without a matching release path, can leak or crash in ways JSON serialization never could. This is the tradeoff for removing the serialization tax — you get direct memory access, and direct memory access requires discipline.
🧠 Check your understanding
0/2 · 0/2 answered1. Under the old React Native bridge, why were all native modules instantiated at app launch regardless of whether they were used?
2. A TurboModule exposes a synchronous method that performs a heavy cryptographic hash directly on the calling thread. What is the main risk?