byprofile_photo

Bridge of Spies: How React Native JS Really Talks to Native Code

How JavaScript talks to native code is one of the most important parts of React Native. For a long time, it was also the most confusing.

In the old days, getting JavaScript to talk to iOS or Android felt like a scene from the movie Bridge of Spies: two sides meeting on a cold, foggy bridge at midnight to trade secret messages.

In this post, we will look at how the old bridge worked, why it was slow, how the new JSI architecture replaced it with a direct hotline, and what “glue code” actually means.

🧵 Two Separate Worlds

React Native separates JavaScript code from native platform code. They run in different places and on different threads:

Even with the new architecture, one basic rule stays the same:

JavaScript runs inside a JS engine (Hermes or JavaScriptCore).

The JS engine does not automatically know how to call your C++, Swift, Objective-C, Java, or Kotlin code. We have to build that bridge ourselves.

So when you write:

Camera.takePicture();

how does that command actually reach the real camera on your phone? Let's start with how it used to work.

🌉 The Old World: The Bridge

The classic React Native Bridge worked like a message delivery service.

When JavaScript called a native method, it could not call native code directly. Instead, it had to turn the call into a serialized JSON-like string, put it into a queue, and send it over the bridge in batches.

How The Old Bridge Handled CallsBatched & Async
1. JavaScript ThreadHermes / JSC

You call Camera.takePicture(). JavaScript serializes the module name, method, and arguments into a JSON-like message.

{"module": "Camera", "method": "takePicture", "args": []}
↓ Message queued and sent in batches
2. Bridge QueueTransit Layer

Batches messages together and pushes them across the boundary to the native thread asynchronously.

↓ Native receives and unpacks message
3. Native PlatformiOS / Android

Native code decodes the arguments, locates the camera module, and fires the actual camera hardware.

When the native code finished, it sent the result back the same way: turning data into a message and sending it across the bridge to JavaScript.

Problems with the Old Bridge:

Summary of the Old Way

The old bridge treated communication between JS and Native as sending text messages back and forth.

⚡ The New World: JSI (JavaScript Interface)

The foundation of the new architecture is JSI (JavaScript Interface).

JSI is a lightweight C++ API that gives React Native a direct handle on the JavaScript engine. It is not the entire module system by itself, but the C++ engine interface that makes direct communication possible.

Instead of serializing everything into a queue, native C++ objects are exposed directly to the JavaScript runtime as native-backed HostObjects.

Old BridgeBatched Message Queue
1. JavaScript calls method
↓ Serialize to JSON string buffer
2. Bridge message queue buffer
↓ Batched dispatch across boundary
3. Native unpacks and executes

Strictly asynchronous with JSON parsing overhead

Modern JSIDirect in Memory
1. JavaScript Engine
↓ Direct in-memory C++ invocation (no JSON)
2. Native C++ HostObject
↓ Typed dispatch (Obj-C++ / JNI)
3. Native Code (Swift / Kotlin)

Direct function calls with native type conversion

JSI provides C++ wrappers for JavaScript values, objects, and functions:

When JavaScript calls a JSI function, it calls native C++ code directly in memory.

Type Conversion & Threading Reality

While JSON string parsing is eliminated, JSI still performs type marshaling between JS values and C++ types. Also, while JSI allows synchronous execution, heavy background operations (camera capture, SQLite, networking) are still dispatched to native worker threads so the JS thread never blocks.

🔤 What Does jsi:: Mean?

In C++, :: is just a way to say “look inside this folder or group”.

React Native groups its JSI types under the facebook::jsi namespace:

🧩 HostObjects: How JSI Works in Practice

A great way to understand JSI is through a HostObject.

A HostObject is a C++ object that looks and acts like a normal JavaScript object. When JavaScript tries to read a property from it, the JS engine calls your C++ get() method.

class HostObject {
public:
  virtual jsi::Value get(
      jsi::Runtime& rt,
      const jsi::PropNameID& name);
 
  virtual void set(
      jsi::Runtime& rt,
      const jsi::PropNameID& name,
      const jsi::Value& value);
 
  virtual std::vector<jsi::PropNameID>
  getPropertyNames(jsi::Runtime& rt);
};

🚀 Let's Build a Simple Native Module by Hand

Let's write a native math helper that gives JS a multiply(a, b) function:

class MathHostObject : public jsi::HostObject {
public:
  jsi::Value get(
      jsi::Runtime& rt,
      const jsi::PropNameID& name) override {
 
    auto propName = name.utf8(rt);
 
    if (propName == "multiply") {
      // Return a C++ function to JavaScript
      return jsi::Function::createFromHostFunction(
        rt,
        jsi::PropNameID::forAscii(rt, "multiply"),
        2, // Takes 2 arguments
        [](jsi::Runtime& rt,
           const jsi::Value& thisVal,
           const jsi::Value* args,
           size_t count) -> jsi::Value {
 
          double a = args[0].asNumber();
          double b = args[1].asNumber();
 
          return jsi::Value(a * b);
        }
      );
    }
 
    return jsi::Value::undefined();
  }
 
  std::vector<jsi::PropNameID>
  getPropertyNames(jsi::Runtime& rt) override {
    std::vector<jsi::PropNameID> names;
    names.push_back(
      jsi::PropNameID::forAscii(rt, "multiply")
    );
    return names;
  }
};

Next, we register it on the JavaScript global object:

void installMathModule(jsi::Runtime& runtime) {
  auto hostObject = std::make_shared<MathHostObject>();
 
  jsi::Object jsObject =
      jsi::Object::createFromHostObject(runtime, hostObject);
 
  runtime.global().setProperty(
      runtime,
      "mathModule",
      jsObject);
}

Now in JavaScript, you can call it immediately:

const result = global.mathModule.multiply(6, 7);
console.log(result); // 42 (Runs synchronously in C++!)
Direct In-Memory Call FlowSynchronous JSI
1. JS Calls Global PropertyJS Engine
global.mathModule.multiply(6, 7)
↓ JS engine delegates property lookup to C++
2. HostObject Resolves MethodC++ JSI
HostObject::get("multiply")
↓ Invokes native C++ lambda in place
3. Native C++ Lambda ExecutionReturns Result

Computes 6 * 7 and returns 42 directly to JavaScript without queue delays.

No message queue. No JSON conversion. Just a direct function call.

⚠️ Why We Don't Write Everything by Hand

Writing C++ by hand like this works, but it takes too much time for a real app.

Imagine having to write args[0].asNumber(), check argument counts, and handle errors manually for 50 different methods. It would be easy to make mistakes.

This is why React Native created TurboModules and Codegen.

🧩 What Is “Glue Code”?

Instead of writing all the C++ wrapper code by hand, you write a simple TypeScript specification:

import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
 
export interface Spec extends TurboModule {
  multiply(a: number, b: number): number;
}
 
export default TurboModuleRegistry.getEnforcing<Spec>('MathModule');

This file defines the contract: “MathModule has a multiply method that takes two numbers and returns a number.”

When you build your app, Codegen automatically reads this TypeScript file and generates all the repetitive C++ code for you:

Build-Time Codegen PipelineAutomated Glue
1. TypeScript SpecYou write
↓ Codegen parses TypeScript contract at build time
2. Generated C++ Glue CodeAuto-generated
↓ Connects to your native method handlers
3. Your Clean Native LogicSwift / Kotlin / C++

Now your native implementation only needs to care about the real logic:

class NativeMathModule : public NativeMathModuleSpecJSI {
public:
  jsi::Value multiply(jsi::Runtime& rt, double a, double b) {
    return jsi::Value(a * b);
  }
};

Under the hood, Codegen outputs C++ JSI bindings. On iOS, these connect through Objective-C++ protocols to your Swift or Objective-C code. On Android, they connect through JNI (Java Native Interface) to your Kotlin or Java code.

🧠 JSI vs. TurboModules vs. Codegen

These three tools work together, but they do different jobs:

NameWhat It IsSimple Analogy
JSILow-level C++ runtime interfaceThe direct phone line between JS and C++
TurboModulesNative module system and lifecycle managerThe manager that creates and finds native modules
CodegenBuild tool that writes glue code automaticallyThe automated translator connecting TS to Native
How The Layers Fit TogetherReact Native Architecture
TurboModules (Lifecycle & Module Lookup)
↓ Manages modules over JSI
JavaScript
JSI (In-Memory)
Native Code
↑ Connected by Codegen (Generated Type-Safe Glue)

📚 Bonus: Lazy Loading

In the old architecture, all native modules were loaded immediately when the app started.

TurboModules are lazy by default:

TurboModuleRegistry.getEnforcing('PaymentModule');

The native module is not created until the exact moment your app calls it.

Why Does This Help?

🏭 What About Fabric?

JSI is used for more than just native modules. Fabric is React Native's modern rendering engine:

Because the UI layout tree lives in C++ and communicates via JSI, React can perform synchronous layout measurement (like text sizing before paint) and enable Concurrent React features without layout jumps.

TurboModules vs FabricBoth Powered by JSI
TurboModules

Hardware & device features (Camera, Bluetooth, Storage)

Fabric

C++ Shadow Tree & synchronous UI layout rendering

↓ Both share the same direct C++ JSI runtime interface ↓

🎬 Quick Summary

The mystery behind the bridge is gone. Instead of trading slow messages across a border, React Native now has a direct, type-safe connection between JavaScript and your phone's native code.

→ Found this useful? Share it with a fellow developer 🚀