DEV Community

Rahul Garg
Rahul Garg

Posted on

React Native JSI Deep Dive — Part 2: The Bridge is Dead, Long Live JSI

"There is no doubt that the grail of efficiency leads to abuse. Programmers waste enormous amounts of time thinking about, or worrying about, the speed of noncritical parts of their programs. We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil." — Donald Knuth, Structured Programming with go to Statements, 1974

Excerpt: Every React Native native module call used to pass through a single chokepoint: the Bridge. It serialized every value to JSON, batched every call into an async queue, and made it impossible to build anything that needed to respond in under 16 milliseconds. JSI replaced it with something deceptively simple — a direct C++ function pointer. No serialization. No queue. No bridge. This post traces a native module call through both architectures so you can see exactly what changed and why it matters.

Series: React Native JSI Deep Dive (12 parts — series in progress) Part 1: The Runtime You Never See | Part 2: The Bridge is Dead, Long Live JSI (You are here) | Part 3: C++ Foundations | Part 4: JSI Functions | Part 5: HostObjects | Part 6: Memory Ownership | Part 7: Platform Wiring | Part 8: Threading & Async | Part 9: Audio Pipeline | Part 10: Storage Engine | Part 11: Module Approaches | Part 12: Debugging


Quick Recap

In Part 1, we established that React Native runs as three execution domains — JS thread (Hermes), UI thread (platform), and native background threads — communicating via message passing. The JS engine exposes a C++ interface called jsi::Runtime. And we left off with a teaser: before JSI, the messaging system between these worlds was a JSON serialization layer called the Bridge.

Now let's open it up and see what was actually inside.


The Problem: The Invisible Tax

Here's a native module you might write in the old architecture:

android/src/main/java/com/myapp/MathModule.java

@ReactMethod
public void multiply(double a, double b, Promise promise) {
    promise.resolve(a * b);
}

And from JavaScript:

App.js

const result = await NativeModules.MathModule.multiply(3, 7);
console.log(result); // 21

Two numbers in, one number out. The actual multiplication takes nanoseconds. But in the old architecture, this call took milliseconds — orders of magnitude slower than the work itself.

Where did all that time go?


The Bridge: How It Actually Worked

The Bridge — formally the BatchedBridge backed by MessageQueue.js — sat between JavaScript and native code. Nearly every JS ↔ native call passed through it. (A few subsystems bypassed it — notably the native animated driver, which serialized the animation graph once and then ran entirely on the UI thread — but all native module calls and most event dispatch went through the Bridge.)

Here's what happened when you called multiply(3, 7):

JavaScript                          Bridge                              Native
    │                                  │                                   │
    │  NativeModules.MathModule        │                                   │
    │     .multiply(3, 7)              │                                   │
    │                                  │                                   │
    │  1. Serialize call:              │                                   │
    │     moduleIDs: [42],             │                                   │
    │     methodIDs: [3],              │                                   │
    │     params: [[3, 7]]             │                                   │
    │     (three parallel arrays —     │                                   │
    │      numeric IDs, not names)     │                                   │
    │                                  │                                   │
    │  2. Enqueue in batch ──────────▶ │                                   │
    │                                  │                                   │
    │                                  │  3. Wait for batch flush          │
    │                                  │     (≥5ms between JS-initiated    │
    │                                  │      flushes, or next native poll)│
    │                                  │                                   │
    │                                  │  4. Flush batch ────────────────▶ │
    │                                  │                                   │
    │                                  │                    5. JSON.parse  │
    │                                  │                    6. Find module │
    │                                  │                    7. Invoke      │
    │                                  │                       method     │
    │                                  │                    8. Compute:    │
    │                                  │                       3 * 7 = 21 │
    │                                  │                                   │
    │                                  │                    9. Serialize   │
    │                                  │                       result     │
    │                                  │  ◀──────────────── 10. Send back │
    │                                  │                                   │
    │  11. Deserialize result ◀─────── │                                   │
    │  12. Resolve promise             │                                   │
    │      result = 21                 │                                   │

Figure 1: A native module call through the Bridge. The actual work (step 8) is a single multiplication. Everything else is overhead.

Count the steps: twelve. The actual computation is step 8 — a single multiplication. The other eleven steps are pure overhead: serialization, queuing, deserialization, dispatch.

Let's break down the two costs the Bridge imposed.


Cost 1: JSON Serialization

Every value that crossed the Bridge was serialized to JSON on one side and parsed back on the other. Numbers, strings, booleans, arrays, objects — everything was converted to a JSON string, transmitted as bytes, and reconstructed from scratch.

For multiply(3, 7), that means the call is encoded into three parallel arrays — moduleIDs, methodIDs, and params — using numeric IDs that map to registered module and method names. The arguments themselves ([3, 7]) are JSON-serialized:

JS side:  Enqueue into batch arrays:
          moduleIDs: [42]        (numeric ID for "MathModule")
          methodIDs: [3]         (numeric ID for "multiply")
          params:    [[3, 7]]    (JSON-serialized arguments)

Native:   Deserialize the batch, look up module 42 / method 3,
          parse the argument array [3, 7]

For two numbers, this is wasteful but survivable. But consider what happens with real data:

Sending a large dataset across the Bridge

// Passing 10,000 items to native for processing
NativeModules.DataProcessor.process(items);

// Bridge must:
// 1. JSON.stringify 10,000 objects (~2-5ms for complex objects)
// 2. Copy the resulting string across the bridge
// 3. JSON.parse on the native side (~2-5ms)
// Total serialization overhead: 4-10ms — before native code even starts

And it wasn't just data size. It was data types. JSON has no concept of typed arrays, binary data, or ArrayBuffers. If you needed to pass image pixels, audio samples, or any binary data to native code, you had two options: Base64-encode it (inflating size by 33% and adding encoding/decoding overhead) or write it to a temporary file and pass the file path.

Neither option let you share memory. Every byte was copied at least twice.

Key Insight: The serialization cost was proportional to the size of data being transferred, not the complexity of the operation. A native function that took 0.1ms to execute could spend 10ms just getting its arguments across the Bridge. The Bridge made cheap operations expensive and made transferring large data impractical.


Cost 2: Async-Only Batching

The Bridge was asynchronous. Every call — even a simple multiplication that could return instantly — was enqueued in a batch queue and processed later. There was no way to make a synchronous native call.

Here's why this mattered. Imagine you're building a key-value store:

The async tax on a simple lookup

// What you WANT to write (synchronous, like localStorage):
const theme = Storage.get('theme');
renderApp(theme);

// What you HAD to write (async, because the Bridge):
const theme = await NativeModules.Storage.get('theme');
renderApp(theme);

The await doesn't just add syntax. It suspends the async function, and because the Bridge dispatched calls to a separate native thread, the result couldn't come back until a future event loop cycle — after the native thread received the batch, executed the call, and sent the result back across the Bridge. For a cache lookup that takes microseconds on the native side, this cross-thread round-trip added milliseconds of latency.

And because the Bridge batched calls, multiple native calls from the same JS execution frame were collected and sent together:

Batching behavior

// These three calls don't execute immediately.
// They're collected into a batch:
NativeModules.Analytics.track('screen_view');
NativeModules.Storage.get('user_id');
NativeModules.Logger.info('App mounted');

// The batch is flushed on the next event loop cycle.
// All three calls cross the Bridge together.
// Results come back asynchronously, in an unspecified order.

Batching was an optimization — sending one message with three calls is cheaper than three separate messages. But it meant you couldn't get a result during the current execution frame. Every native call was a round trip through the event loop.

Gotcha: The Bridge's batching behavior created subtle bugs. If you called two native methods that depended on each other — say, write('key', 'value') followed by read('key') — they were batched together, but execution order on the native side wasn't guaranteed to match call order. Race conditions in Bridge-based native modules were a common source of bugs that were nearly impossible to reproduce.


Where It Broke Down

For simple apps with occasional native calls, the Bridge was fine. Millions of React Native apps shipped on it. But it had a ceiling, and three categories of work hit that ceiling hard:

High-Frequency Events

JS-driven scroll-linked animations were a common pain point. When a scroll event needed to update a JS-driven animation, each event triggered a Bridge round-trip: the event was serialized to JSON on the native side, deserialized on the JS side, processed by JavaScript (which computed the new animation value), and the result serialized back to native for the UI update. If any step took longer than the frame budget, the animation stuttered.

Scroll event driving a JS animation:

  UI Thread                Bridge                 JS Thread
     │                       │                       │
     │  Scroll offset ──▶    │                       │
     │                       │  Serialize ──▶        │
     │                       │                       │  Process event
     │                       │                       │  Compute animation
     │                       │    ◀── Serialize      │
     │  ◀── Deserialize      │                       │
     │                       │                       │
     │  Apply update         │                       │
     └───────────────────────┴───────────────────────┘
                     Must complete in <16ms

Figure 2: A scroll-driven animation round-trip through the Bridge. Each event requires serialization in both directions — all within a single frame budget.

Each round-trip involves serialization and deserialization in both directions — four JSON operations per event. At high scroll velocities, these events can fire dozens of times per second, compounding the overhead quickly. (This is exactly why React Native introduced the native animated driver — useNativeDriver: true — which bypassed the Bridge entirely for animations. But any scroll-linked logic that required JavaScript computation had no escape hatch.)

Large Data Transfers

Passing images, audio buffers, or large datasets across the Bridge required serializing the entire payload to JSON (or Base64). There was no way to share a memory pointer. A 1MB audio buffer became a 1.33MB Base64 string that was copied, transmitted, parsed, and decoded — turning a zero-cost pointer share into a multi-millisecond copy operation.

Synchronous Lookups

Some operations are fundamentally synchronous. Reading a cached value, checking a feature flag, getting the current timestamp from a high-resolution native timer — these operations complete in microseconds on the native side. But the Bridge forced them through an async round-trip, adding milliseconds of overhead to microsecond operations.

This is why libraries like react-native-mmkv couldn't exist in the old architecture. MMKV's entire value proposition is synchronous key-value access — storage.getString('key') returns immediately, no await. That's only possible with JSI.


JSI: The Replacement

The JavaScript Interface (JSI) replaces the Bridge with something fundamentally different: instead of serializing messages between two separate worlds, JSI lets JavaScript hold references to C++ host objects and functions — managed through the runtime, without any JSON serialization layer in between.

No serialization. No queue. No batch. No bridge.

Here's the same multiply operation with JSI:

JavaScript                                    C++ (via JSI)
    │                                             │
    │  multiply(3, 7)                             │
    │                                             │
    │  1. Call C++ function pointer ────────────▶  │
    │     (args passed as jsi::Value,              │
    │      no serialization)                      │
    │                                             │  2. Read args directly:
    │                                             │     a = args[0].asNumber()
    │                                             │     b = args[1].asNumber()
    │                                             │  3. Compute: 3 * 7 = 21
    │                                             │  4. Return jsi::Value(21)
    │                                             │
    │  ◀──────────────────────────────────────────│
    │  5. result = 21                             │
    │     (no deserialization)                    │
    │                                             │

Figure 3: The same multiply call through JSI. Five steps instead of twelve. No serialization, no queue, no batch.

Five steps instead of twelve. And steps 1 and 5 are essentially free — they're a C++ function call and a return value. The entire overhead is a function pointer invocation.

Let's unpack what makes this possible.


How JSI Works: Function Pointers, Not Messages

When you register a JSI function, you're giving the JavaScript runtime a C++ function pointer disguised as a JavaScript function. From JavaScript's perspective, it's just a function. From C++'s perspective, it's a lambda that receives the runtime and arguments directly.

Registering a JSI function (simplified)

// C++ side: install a function into the JS runtime
runtime.global().setProperty(
    runtime,
    "multiply",
    jsi::Function::createFromHostFunction(
        runtime,
        jsi::PropNameID::forAscii(runtime, "multiply"),
        2,  // argument count
        [](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);
        }
    )
);

Calling it from JavaScript

const result = multiply(3, 7);  // 21 — synchronous, no await needed

Think about it: Notice what's missing. There's no await. There's no Promise. There's no callback. The function call is synchronous — JavaScript calls it, C++ executes, the result is returned immediately on the same thread, in the same event loop tick. How is that possible when the Bridge required everything to be async?

The answer is thread affinity. The Bridge was async because it sent messages between threads — the JSON payload was produced on the JS thread and consumed on a native thread. JSI functions run on the JS thread itself. The C++ code executes in the same thread that called it. No cross-thread messaging means no async overhead.

This is why Part 1 emphasized that jsi::Runtime is confined to the JS thread. That constraint — which might have seemed limiting — is what makes synchronous calls possible.


Values Without Serialization

The Bridge converted everything to JSON. JSI passes values directly as jsi::Value — a C++ type that can hold any JavaScript value without converting it to a string first.

Here's how JavaScript types map to JSI types:

JavaScript Type Bridge (old) JSI (new)
`number` JSON number → string → parse back `jsi::Value` wrapping `double` — zero conversion
`string` JSON string → escaped → parse back `jsi::String` — engine-native string, no JSON serialization
`boolean` JSON `true`/`false` → string → parse `jsi::Value` wrapping `bool` — zero conversion
`object` JSON.stringify entire tree `jsi::Object` — direct handle, no copying
`array` JSON.stringify entire array `jsi::Array` (a `jsi::Object`) — direct handle
`ArrayBuffer` Not supported (Base64 workaround) `jsi::ArrayBuffer` — zero-copy pointer to raw bytes
`function` Not passable `jsi::Function` — callable from C++

Figure 4: Value type mapping between the Bridge and JSI. The Bridge serialized everything to strings. JSI preserves native types.

The most important row is ArrayBuffer. The Bridge had no way to pass binary data without encoding it. JSI gives you jsi::ArrayBuffer — a direct pointer to a block of raw bytes shared between JavaScript and C++. No copy, no encoding, no overhead.

Zero-copy access to binary data

// C++ reads directly from JS ArrayBuffer — no copy
auto buffer = args[0].asObject(rt).getArrayBuffer(rt);
uint8_t* data = buffer.data(rt);   // raw pointer to JS memory
size_t length = buffer.size(rt);    // size in bytes

// Process the bytes in-place — JS and C++ see the same memory
for (size_t i = 0; i < length; i++) {
    data[i] = processAudioSample(data[i]);
}

This is how audio pipelines, camera processors, and ML inference can work in React Native — binary data flows between JS and native without a single copy.

Key Insight: JSI doesn't just make the Bridge faster. It makes an entirely new category of operations possible. Zero-copy binary data sharing, synchronous function calls, passing functions and objects between JS and C++ — none of these could work through a JSON serialization layer. JSI isn't an optimization of the Bridge. It's an elimination of the Bridge.


Bridgeless Mode: The Default Path Is JSI

Starting with React Native 0.76, Bridgeless Mode is the default. All new JS ↔ native communication goes through JSI — not the classic Bridge.

The Bridge code is not fully removed from the codebase yet — React Native provides an automatic interop layer so that old-style native modules (those using @ReactMethod and BatchedBridge) continue to work during the migration period. But the interop layer is a compatibility shim, not the primary architecture. New native modules should target JSI directly, and the React Native team has stated that the bridge code and interop layer will be removed entirely in a future release.

React Native ≤ 0.72:    Bridge ON, JSI optional
React Native 0.73–0.75: Bridge ON, JSI encouraged (New Architecture opt-in)
React Native 0.76+:     JSI default (Bridgeless Mode), Bridge interop layer for legacy modules

Terminology: Bridgeless Mode means the classic JSON bridge (BatchedBridge, MessageQueue.js) is no longer the primary communication path. All new JS ↔ native communication uses JSI. An automatic interop layer keeps legacy modules working, but this shim is temporary — full bridge removal is planned. Bridgeless Mode is part of the broader "New Architecture" that also includes Fabric (the new renderer) and TurboModules (codegen-based native modules built on JSI).


The Tradeoffs (Nothing Is Free)

JSI isn't a free lunch. The Bridge had properties that were genuinely useful:

Property Bridge JSI
**Thread safety** Inherently safe — JSON messages can be sent from any thread Must only access `jsi::Runtime` from the JS thread
**Debugging** Messages are JSON — easy to log, intercept, replay C++ function calls — harder to trace without native debuggers
**Language barrier** Any language can produce/consume JSON Must write C++ (or Objective-C++ / JNI wrappers)
**Crash surface area** Native modules in Java/Kotlin/Swift — managed memory, fewer crash vectors C++ with manual memory — segfaults, use-after-free, and undefined behavior are possible
**Simplicity** `@ReactMethod` annotation, Java/Kotlin/Swift only C++ required for direct JSI, plus platform wiring

The Bridge traded performance for simplicity. JSI trades simplicity for performance. For most apps — where native calls are infrequent and data payloads are small — the Bridge was perfectly adequate. JSI becomes essential when you need synchronous access, binary data, or high-frequency native calls.

Feynman Moment: Here's where the "Bridge is dead" headline is slightly misleading. The mechanism is dead — no more JSON serialization and async queuing. But the pattern of sending messages between threads is alive and well. When you do heavy work on a background thread and send the result back to the JS thread via CallInvoker, that's still message passing. JSI eliminated the Bridge as an implementation detail, but it didn't eliminate the need for async communication between threads. The three-thread architecture from Part 1 hasn't changed. What changed is the cost of crossing the boundary when you're already on the right thread.


In Practice: Seeing the Difference

Let's make the performance difference concrete. Consider a storage module that reads a cached value:

Bridge (old architecture):

Bridge-based storage read (AsyncStorage)

// Average time: ~0.24ms per read (measured via StorageBenchmark)
const value = await NativeModules.Storage.get('user_theme');
// 1. Serialize call into batch arrays
// 2. Enqueue in batch
// 3. Wait for batch flush
// 4. Send to native thread
// 5. Deserialize on native side
// 6. Read from storage
// 7. Serialize result
// 8. Send back to JS thread
// 9. Deserialize result
// 10. Resolve promise

JSI (new architecture):

JSI-based storage read (react-native-mmkv)

// Average time: ~0.012ms per read (measured via StorageBenchmark)
const value = storage.getString('user_theme');
// 1. Call C++ function pointer
// 2. Read from memory-mapped storage
// 3. Return jsi::String
// Done.

Benchmarks from mrousavy/StorageBenchmark show MMKV at ~0.012ms per read vs AsyncStorage at 0.24ms — roughly a **20x speedup**. The MMKV README reports ~30x faster than AsyncStorage. The exact ratio varies by device and payload size, but the order of magnitude is consistent: not because the storage engine got faster, but because the serialization and async overhead disappeared.

Gotcha: This doesn't mean you should make everything synchronous. A synchronous JSI call that takes 50ms blocks the JS thread for 50ms — no touch events, no timers, no callbacks. Rule of thumb: if the operation completes in under 1ms, synchronous is fine. If it might take over 5ms, use a background thread with CallInvoker and return a Promise. We'll cover this pattern in detail in Part 8.


Key Takeaways

  • The Bridge serialized everything to JSON. Every native call — no matter how simple — paid the cost of JSON.stringify on one side and JSON.parse on the other. This made data size the dominant factor in call latency, not computational complexity.

  • The Bridge was async-only. Every call was batched and processed on the next event loop tick. There was no way to get a synchronous result, even for operations that completed in microseconds.

  • JSI replaces serialization with function pointers. JavaScript holds managed references to C++ host functions and objects through the runtime. Calls are synchronous (on the JS thread), values are passed as jsi::Value (no JSON conversion), and binary data can be shared zero-copy via jsi::ArrayBuffer.

  • Bridgeless Mode is the default since RN 0.76. JSI is the primary communication path. An interop layer keeps legacy Bridge-based modules working during migration, but the Bridge is no longer the default and will be fully removed in a future release.

  • JSI trades simplicity for performance. The Bridge let you write native modules in Java/Swift with @ReactMethod. JSI requires C++ for direct access. This is the cost of eliminating the serialization layer — and it's why Part 3 of this series teaches you the C++ you need.


What's Next

JSI gives JavaScript direct access to C++ functions. But to write those functions, you need to write C++. And if you're a JavaScript developer, C++ probably looks like it was designed to cause suffering.

Good news: you don't need to learn all of C++. You need a specific subset — the parts that matter for JSI native modules: stack vs heap, RAII, smart pointers, lambdas, and move semantics. That's it. No templates-of-templates, no operator overloading, no multiple inheritance.

In Part 3: C++ for JavaScript Developers, we'll learn exactly that subset — framed in terms you already understand from JavaScript. unique_ptr is a const reference you can't copy. shared_ptr is a garbage-collected pointer with a reference count. RAII is try/finally built into the language.

You'll write your first JSI function in Part 4. But Part 3 gives you the vocabulary to understand what that function is doing at the memory level — and why it doesn't leak, crash, or corrupt your app.


References & Further Reading

  1. React Native — The New Architecture (Official Documentation)
  2. JSI Source Code — facebook/react-native (jsi.h API Surface)
  3. React Native 0.76 — The New Architecture Is Here
  4. React Native Working Group — Bridgeless Mode Discussion
  5. react-native-mmkv — JSI-based Synchronous Storage (Source Code)
  6. mrousavy/StorageBenchmark — MMKV vs AsyncStorage Performance Comparison
  7. React Native — Threading Model (Architecture Docs)
  8. MessageQueue.js — BatchedBridge Implementation (Source Code)
  9. Tadeu Zagallo — Bridging in React Native (Core Engineer Writeup)

Series: React Native JSI Deep Dive (12 parts — series in progress) Part 1: The Runtime You Never See | Part 2: The Bridge is Dead, Long Live JSI (You are here) | Part 3: C++ Foundations | Part 4: JSI Functions | Part 5: HostObjects | Part 6: Memory Ownership | Part 7: Platform Wiring | Part 8: Threading & Async | Part 9: Audio Pipeline | Part 10: Storage Engine | Part 11: Module Approaches | Part 12: Debugging

Top comments (0)