DEV Community

Rahul Garg
Rahul Garg

Posted on

React Native JSI Deep Dive — Part 1: The Runtime You Never See

React Native JSI Deep Dive — Part 1: The Runtime You Never See

"The most dangerous thought you can have as a creative person is to think you know what you're doing." — Bret Victor, Inventing on Principle, 2012

Excerpt: You've built React Native apps for years. You know useState, you know FlatList, you know how to call a native module. But do you know what happens in the 16 milliseconds between your onPress handler and the pixel changing on screen? Three execution threads, two runtime environments (JavaScript via Hermes, native via Objective-C/Java/C++), and a message-passing architecture that explains every performance problem you've ever had.

Series: React Native JSI Deep Dive (12 parts — series in progress) Part 1: The Runtime You Never See (You are here) | Part 2: Bridge → JSI | 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


The Problem: The Illusion of One World

Here's something that should bother you: when you write a React Native component, it feels like you're writing a single program.

App.tsx

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <TouchableOpacity onPress={() => setCount(count + 1)}>
      <Text>{count}</Text>
    </TouchableOpacity>
  );
}

One file. One function. One mental model: user taps, state changes, screen updates. It feels no different from a web app.

But this is an illusion. When you press that button, your tap crosses three execution threads, passes through two runtime environments — JavaScript (Hermes) and native (Objective-C/Java/C++) — and triggers a cascade of messages between worlds that don't share memory, don't share a clock, and barely speak the same language.

Every performance problem you've ever encountered in React Native — janky scrolling, delayed touch responses, slow native module calls, mysterious frame drops — traces back to this hidden architecture. Understanding it doesn't just explain the problems. It makes the solutions feel inevitable.


The Three Threads

React Native is not a single-threaded JavaScript application that talks to native views. It behaves like a distributed system running on your phone — independent execution domains communicating via message passing. The official architecture docs describe two primary threads — JS and UI — but in practice, native module work runs on background thread pools, making the effective model three execution domains. In the New Architecture, the Fabric renderer may use additional threads for layout and shadow tree operations, but the JS/UI/Background mental model remains the most useful conceptual starting point.

Thread 1: JavaScript

This is where your code runs. Your components, your hooks, your business logic, your API calls — all of it executes here, inside a JavaScript engine.

On React Native 0.76+, that engine is Hermes — a JavaScript VM designed specifically for React Native. Hermes doesn't compile your code at runtime like V8 does in Chrome. Instead, the Hermes compiler (hermesc) pre-compiles your JavaScript to bytecode during the Metro bundling step — before your app is even installed. When the app starts, Hermes executes the bytecode directly — no parsing, no JIT compilation, no warm-up.

Terminology: Hermes is a JavaScript engine optimized for React Native. It uses ahead-of-time bytecode compilation (via hermesc during Metro bundling), a concurrent generational garbage collector (Hades), and a memory model tuned for mobile constraints. It's not V8 (Chrome) or JavaScriptCore (Safari) — it's purpose-built.

The JS thread has one event loop, just like a browser. When your onPress handler fires, it goes into the event loop queue. When a fetch response arrives, its callback goes into the queue. When a timer fires, same queue. One thread processes them one at a time, in order.

This means JavaScript is fundamentally single-threaded. While your event handler runs, nothing else happens on this thread. No other callback fires. No state update processes. If your handler takes 100ms (say, sorting a large array), the UI is frozen for those 100ms — not because the screen can't update, but because the JS thread is busy and can't process the next item in the queue.

Thread 2: UI (Main Thread)

This is the platform's main thread — the one that actually draws pixels and handles touch events. On iOS, it's the thread that runs UIKit. On Android, it's the one that runs the View system.

The UI thread has one overriding constraint: it must submit work to the rendering pipeline within the frame budget — 16.6ms (60fps) or 8.3ms (120fps on newer devices). If it misses a frame, the user sees a stutter. If it misses several, the interface feels broken.

This thread does not run JavaScript. It runs native platform code — layout calculations, view hierarchy updates, animation interpolations, touch hit-testing. Your React components don't exist here. What exists here are native views — UIView on iOS, android.view.View on Android — that React Native's rendering system creates and manages on your behalf.

Key Insight: When you write <Text>Hello</Text> in React Native, no Text component exists on the UI thread. Instead, React Native creates a native UILabel (iOS) or TextView (Android) — a real platform view that the operating system knows how to render. Your React tree is a description of what the UI should look like. The UI thread holds the reality.

Thread 3: Native Modules (Background)

The third thread — or more accurately, a pool of background threads — handles native module work. When your JavaScript calls AsyncStorage.getItem('key'), the work doesn't happen on the JS thread or the UI thread. It's dispatched to a background thread where native code reads from disk, then the result is sent back to the JS thread.

This is where native modules live: camera access, file I/O, biometric auth, Bluetooth — anything that talks to platform APIs.


How They Talk: Messages, Not Memory

Here's the critical insight: these three threads do not share memory. The JS thread cannot read a variable from the UI thread. The UI thread cannot call a JavaScript function directly. They communicate by passing messages.

Think of three people in separate soundproof rooms, communicating by sliding notes under the doors.

┌─────────────┐     messages     ┌─────────────┐     messages     ┌─────────────┐
│             │    ──────────▶   │             │    ──────────▶   │             │
│  JS Thread  │                  │  UI Thread  │                  │   Native    │
│  (Hermes)   │    ◀──────────   │  (UIKit /   │    ◀──────────   │   Modules   │
│             │     messages     │   Android)  │     messages     │             │
└─────────────┘                  └─────────────┘                  └─────────────┘

When your onPress handler calls setCount(count + 1), here's the actual sequence:

  1. JS thread: React runs your handler, computes the new virtual DOM, diffs it against the previous one, and determines that the Text component's content changed from "0" to "1".

  2. JS thread → UI thread: React Native dispatches an update: "Update the text property of native view #42 to '1'."

  3. UI thread: Receives the message, finds native view #42 (a UILabel or TextView), updates its text property, and includes it in the next frame render.

The user sees "1" on screen. Total elapsed time: anywhere from 1ms (if everything aligns perfectly) to 100ms+ (if either thread is busy).

Feynman Moment: The soundproof rooms analogy is useful but breaks in an important way. Real soundproof rooms have a fixed door — you always pass notes through the same slot. In React Native, the mechanism for passing notes changed completely between the old and new architecture. The old architecture used a JSON serialization bridge (slow, asynchronous). The New Architecture — often called Bridgeless Mode — uses JSI, a C++ interface that lets the rooms share certain objects directly. That's what the rest of this series is about.


Let's Trace a Button Press

Here's the full path a tap takes through the system — from finger to pixel:

User Tap
   │
   ▼
UI Thread (touch hit-testing)
   │
   ▼
EventDispatcher → JSI
   │
   ▼
JS Thread (onPress handler)
   │
   ▼
React reconciliation (diff)
   │
   ▼
Fabric commit
   │
   ▼
UI Thread (native view update)
   │
   ▼
Next frame render → pixel changes

Let's walk through each step with real timing.

0ms — Touch begins. The user's finger contacts the screen. The operating system detects this on the UI thread and begins hit-testing: which native view is under the finger?

1ms — Touch dispatched. The UI thread identifies the native TouchableOpacity view and dispatches the event through React Native's EventDispatcher, which delivers it to the JavaScript runtime: "Touch began at coordinates (x, y) on view #37." (In the New Architecture, this delivery goes through JSI rather than the legacy bridge.)

2ms — JS processes the event. The JS thread picks up the message from its event loop queue. React's event system maps view #37 to your onPress callback. Your handler runs: setCount(count + 1).

3-5ms — React reconciliation. React runs the component again with the new state. It diffs the new virtual DOM against the previous one. It finds one change: the Text node's children changed from "0" to "1".

5-6ms — Fabric commit. React Native's rendering system — Fabric — packages the change and commits it. Fabric is the modern renderer that replaced the legacy UIManager; it coordinates layout and mounting between the JS and UI threads. The commit message: "Set property 'text' to '1' on shadow node #42."

6-7ms — UI thread applies the update. The UI thread receives the update, modifies the native view, and marks the view hierarchy as needing a redraw.

16ms — Next frame. The OS composites the updated view hierarchy into the next display frame. The user sees "1".

Total: ~16ms for a simple counter. But notice: the JS thread did its work in ~4ms. The rest is waiting — waiting for the message to reach the UI thread, waiting for the next frame boundary.

Now imagine what happens when those 4ms of JS work become 40ms. Or when the UI thread is busy running a complex animation. Or when a native module call inserts another round trip. The messages pile up, the threads fall out of sync, and the user feels the lag.


The Event Loop (Your Friend and Your Bottleneck)

The JS thread runs a single event loop. Everything — touch handlers, timers, network callbacks, native module results — enters the same queue and is processed one at a time.

Event Loop Queue:
┌──────────────────────────────────────────────────┐
│ onPress() │ setTimeout() │ fetch() callback │ ... │
└──────────────────────────────────────────────────┘
     ▲                                        │
     │         Process one at a time           │
     └─────────────────────────────────────────┘

This is conceptually similar to how browsers process JavaScript tasks. And it has the same fundamental implication: any single task that takes too long blocks everything behind it.

Blocking the event loop

function onPress() {
  // This blocks the entire JS thread for ~200ms
  const sorted = hugeArray.sort((a, b) => a.localeCompare(b));
  setData(sorted);
}

While sort() runs, no touch events are processed, no animations are driven from JS, no network callbacks fire, no timers execute. The app appears frozen — not because the UI thread is stuck (it's still rendering the old state perfectly smoothly), but because the JS thread can't tell it to change anything.

Gotcha: This is why React Native animations should use the native driver (useNativeDriver: true) whenever possible. A JS-driven animation means the JS thread must send a position update message to the UI thread every 16ms. If the JS thread is busy, it misses frames and the animation stutters. A native-driven animation runs entirely on the UI thread — the JS thread doesn't need to participate at all.


Hermes: The Engine Under the Hood

The JS thread doesn't run JavaScript directly. It runs Hermes, a JavaScript engine with several properties that matter for React Native:

Bytecode Compilation

Unlike V8 (which parses JavaScript source code and JIT-compiles it to machine code at runtime), Hermes compiles JavaScript to bytecode during the Metro bundling step using the hermesc compiler. In most production builds, the app bundle ships Hermes bytecode rather than raw JavaScript source.

V8 (Chrome):     Source code → Parse → AST → JIT compile → Machine code
                  (all at runtime — causes startup delay)

Hermes (RN):     Source code → hermesc → Bytecode (during bundling)
                  Bytecode → Execute directly (at runtime — fast startup)

This is why React Native apps with Hermes start faster: there's no parsing or compilation step when the app launches. The tradeoff is that Hermes executes bytecode directly without a JIT compiler — no runtime compilation to native machine code. This improves startup time and memory usage at the cost of peak compute performance compared to JIT engines like V8. For most React Native workloads (UI-driven, event-based, not compute-heavy), direct bytecode execution is fast enough. For compute-heavy work, you should be in native code anyway — which is exactly what this series teaches.

The Hades Garbage Collector

Hermes uses a concurrent generational garbage collector called Hades. "Concurrent" means it can collect unused memory while your JavaScript is running, rather than stopping the world to scan the heap. "Generational" means it separates objects into young and old generations — young objects (recently allocated, likely short-lived) are collected frequently and cheaply, while old objects (survived multiple collections) are collected less often.

This matters because GC pauses are one of the most common causes of frame drops in JavaScript applications. A traditional stop-the-world GC might pause for 5-20ms — enough to miss a frame. Hades keeps pauses short (usually well below the frame budget) by doing most of its work on a background thread.

Key Insight: Hades is one reason React Native 0.76+ feels smoother than older versions. The old JavaScriptCore engine had a stop-the-world GC that could pause for 10-30ms. Hermes's concurrent GC keeps those pauses below the frame budget, even with large heaps.

The jsi::Runtime Interface

Here's where things get interesting for the rest of this series. Hermes doesn't just run JavaScript — it exposes a C++ interface called jsi::Runtime that native code can use to interact with the JavaScript world.

Through jsi::Runtime, C++ code can:

  • Create JavaScript objects and functions
  • Call JavaScript functions
  • Read and write JavaScript values
  • Expose C++ functions that JavaScript can call synchronously

This interface — JSI, the JavaScript Interface — is what the New Architecture is built on. It's what replaced the JSON bridge. And it's what the rest of this series teaches you to use.

But we're getting ahead of ourselves. For now, the important thing is: Hermes is not a black box. It has a C++ API surface. Native code can reach into the JavaScript world — and JavaScript can reach into native code — without serializing anything to JSON.


Why This Model Matters

Understanding the three-thread architecture isn't academic. It directly predicts the behavior of every native module you'll build in this series.

Thread affinity: JSI calls must run on the JS thread. Why? Because jsi::Runtime access is confined to the JavaScript runtime thread — accessing jsi::Runtime or any jsi::Value objects from other threads leads to undefined behavior — because JSI values are bound to a specific runtime instance, and that runtime is not thread-safe. This single constraint shapes the entire design of every native module: heavy work goes to background threads, results come back to the JS thread via CallInvoker.

Message-passing: The old bridge serialized every call to JSON and sent it as a message. The new architecture (JSI) allows synchronous calls — but only on the JS thread. Understanding when to use synchronous calls (fast lookups, <1ms) vs asynchronous calls (I/O, computation, >5ms) is a core skill for native module design.

Event loop: A synchronous native module call that takes 50ms blocks the entire JS thread for 50ms. No touch events, no timers, no callbacks. This is why real-time systems like audio pipelines can't be driven from JavaScript — the event loop's timing is fundamentally unpredictable.

Every part of this series is a consequence of this architecture:

Part Consequence of the Architecture
Part 2 The bridge serialized messages to JSON. JSI eliminates serialization.
Part 4 JSI functions run synchronously on the JS thread — fast but blocking.
Part 5 HostObjects let C++ objects live in the native heap with JS handles.
Part 6 JS GC and C++ heap are separate — ownership must be explicit.
Part 8 Background threads need CallInvoker to send results back to JS.
Part 9 Audio callbacks can't touch JSI — they run on a different thread.

Key Takeaways

  • React Native behaves like a distributed system. Three execution domains — JS, UI, Native background — communicate via message passing, not shared memory. Every performance issue traces back to this architecture.

  • Hermes is the JS engine. It uses ahead-of-time bytecode compilation via hermesc during bundling (fast startup, no JIT), concurrent generational garbage collection via Hades (short pauses), and exposes a C++ interface (jsi::Runtime) that native code can call directly.

  • The JS thread has one event loop. One queue, one item at a time. Any task that blocks the event loop blocks everything: touch events, timers, network callbacks, animations driven from JS.

  • The UI thread must produce a frame every 16ms. It doesn't run JavaScript. It runs native platform code. Your React components are descriptions; the UI thread holds the reality.

  • jsi::Runtime is confined to the JS thread. Accessing the runtime or any JSI values from other threads leads to undefined behavior. Background work must return results through CallInvoker. This single constraint drives the design of every native module in the New Architecture. If you remember one thing from this post, remember this.


What's Next

Now you know the architecture. Three threads, message-passing, a JS engine with a C++ API. But we skipped a crucial chapter: how did messages get from JS to native before JSI?

Legacy Architecture:          New Architecture (Bridgeless):

  JS Thread                     JS Thread
     │                             │
     ▼                             ▼
  Bridge Queue                  jsi::Runtime
  (batched JSON messages)          │
     │                             ▼
     ▼                          Direct C++ call
  Native deserializes              │
     │                             ▼
     ▼                          Native code
  Native code

The answer is the Bridge — a JSON serialization layer that was simple, reliable, and brutally slow. In Part 2 (coming soon), we'll trace a native module call through the Bridge, understand exactly why it was a bottleneck, and see how JSI eliminates the problem entirely.

Series status: This is Part 1 of a 12-part series currently being written. Follow heart-IT for updates as new parts are published.


References & Further Reading

  1. React Native — The New Architecture (Official Documentation)
  2. Hermes — JavaScript Engine for React Native
  3. React Native 0.76 — New Architecture by Default
  4. React Native — Threading Model (Architecture Docs)
  5. Meta Engineering Blog — Hermes: An Open Source JavaScript Engine Optimized for Mobile

Series: React Native JSI Deep Dive (12 parts — series in progress) Part 1: The Runtime You Never See (You are here) | Part 2: Bridge → JSI | 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)