Skip to content
← Journal
7 min readAta Mohammadi

React Native's New Architecture: JSI, Fabric and the Bridgeless TurboModule

The bridge is gone — removed outright, not deprecated. What replaced it is a set of C++ abstractions with real performance characteristics, and a new generation of native modules that compile straight to JSI bindings.

For most of React Native's life, the answer to "why is this slow?" was the same: the bridge. Every touch event, every layout result, every native call was JSON, serialised on one thread, queued, and deserialised on another. Under a fast scroll the queue backed up and you got blank cells; under a heavy animation you got dropped frames; and there was nothing you could do about it from JavaScript.

That is over. Not deprecated — removed. React Native 0.80 stopped supporting the old architecture in new projects, 0.82 deleted the legacy bridge outright, and everything from there is bridgeless. If your mental model of React Native still contains a message queue, it is describing a thing that no longer exists in the codebase.

What replaced it is worth understanding properly, because the new performance characteristics are different — not universally better, different — and knowing where the remaining costs are is the difference between an app that feels native and one that merely benchmarks well.

JSI: the thing everything else is built on

JSI (JavaScript Interface) is a thin C++ API that lets native code hand JavaScript a host object: an object whose property reads and method calls are backed by C++ functions rather than by a JavaScript heap object.

The consequences fall out immediately:

  • No serialisation. A JS call reaches C++ as a jsi::Value, not as parsed JSON.
  • Synchronous calls are possible. JavaScript can invoke native code and get a return value on the same stack. Layout measurement, which previously had to be asynchronous, becomes a function call.
  • Real references can cross. You can hand JavaScript a pointer-backed handle to a native resource — a database connection, a decoded image, a GPU texture — instead of copying its contents.
  • The engine is swappable. JSI is an interface, not a Hermes feature, which is why Hermes and JavaScriptCore are both viable backends.

The third point is the one people underrate. An animation driven by a native handle never round-trips values through JavaScript at all, which is the entire reason Reanimated's worklets and Skia's canvas can hit 120 Hz.

Fabric: an immutable tree you can build off the main thread

Fabric is the renderer. Three properties matter:

Layout runs in C++. Yoga, the flexbox implementation, computes geometry without touching JavaScript or the UI thread.

The shadow tree is immutable. A render produces a new tree that shares unchanged subtrees with the old one. This is the same trick React uses in JavaScript, applied one layer down — and it is what makes concurrent rendering safe on native, because a half-finished tree can be discarded without ever having been visible.

Commits are atomic. The new tree is mounted in one operation. There is no intermediate state where half the screen is updated.

The practical effect is that React 19's concurrent features — transitions, Suspense, interruptible rendering — behave on native the way they do on the web, because the renderer underneath can actually be interrupted.

TurboModules and codegen

A TurboModule is a native module exposed as a JSI host object, with two changes from the old NativeModules:

Lazy initialisation. Modules are constructed the first time JavaScript touches them. The old architecture initialised every registered module at startup, which is why apps with forty native dependencies took a second to boot before running a line of product code.

Codegen. You declare the interface in TypeScript, and a generator emits the C++ and the platform-side scaffolding:

// src/NativeDeviceInfo.ts
import type { TurboModule } from "react-native";
import { TurboModuleRegistry } from "react-native";

export interface Spec extends TurboModule {
  readonly getDeviceName: () => string;
  readonly getBatteryLevel: () => number;
  readonly setBrightness: (value: number) => Promise<void>;
}

export default TurboModuleRegistry.getEnforcing<Spec>("DeviceInfo");

The TypeScript file is not documentation for the native side — it is the source of truth that generates it. A mismatch between the declared signature and the Objective-C or Kotlin implementation is a compile error, not a runtime undefined. Having done it the old way for years, this is the single biggest quality-of-life change in the New Architecture.

Nitro Modules: the next rung

Nitro takes the same idea further. Where a TurboModule still routes through a generated module interface, Nitro generates near-direct JSI bindings, with static dispatch and no dictionary lookup per call. It supports C++, Swift and Kotlin directly, including Swift without an Objective-C bridging layer.

The benchmark that gets quoted is 100,000 native calls: roughly 7 ms with Nitro against roughly 115 ms with TurboModules — a ~15× difference, and far larger against Expo Modules.

That number is real and it is also usually irrelevant, which is the part a senior engineer should say out loud. The delta is about one microsecond per call. If your module is called a handful of times per screen, the difference is unmeasurable. It matters when, and only when, you are crossing the boundary thousands of times per frame:

  • Per-frame sensor or gesture sampling
  • Audio processing callbacks
  • Per-row work in a virtualised list during a fling
  • Streaming decode where each chunk crosses the boundary

Outside those, choose on ergonomics. TurboModules are the framework default with the widest documentation. Expo Modules have by far the best authoring experience if you are in the Expo ecosystem. Nitro is what you reach for when the profiler has already told you the boundary is the bottleneck.

And design the API so the question rarely arises: one call that returns a thousand rows always beats a thousand calls that return one row, no matter which module system you picked. The best boundary optimisation is not crossing it.

What this changed downstream

The New Architecture is not just internals — it unlocked things that were previously impossible, and the clearest example is list rendering.

FlatList virtualises by unmounting off-screen rows and mounting new ones. Under a fast fling, mounting cannot keep up, and you see blank cells. Mitigating it meant tuning windowSize, maxToRenderPerBatch, updateCellsBatchingPeriod — a pile of knobs that traded memory against blankness.

FlashList v2 is a ground-up rewrite that recycles native views instead, rebinding data into containers that already exist. It can do that because synchronous layout measurement exists now: it measures items, computes exact positions, and corrects them before anything paints.

The consequence people miss during migration is that v2 removed the estimate props. estimatedItemSize, estimatedListSize and estimatedFirstItemOffset are deprecated — the library measures instead of guessing. And v2 is New-Architecture-only, by design; it will not run on the old one.

If your notes say "always set estimatedItemSize accurately", they are quoting a migration guide whose actual instruction is to delete it.

Migrating, honestly

Most of the migration is not your code. It is your dependencies.

  1. Audit first. Every native dependency needs a New-Architecture-compatible version. The React Native directory flags support. One unmaintained module can block the whole move, and finding that out at the start is worth a day.
  2. Turn on interop mode. The interop layer runs legacy modules and legacy view managers under the New Architecture. It is a transition tool with real overhead — use it to unblock the migration, not as a destination.
  3. Expect layout differences. Yoga's behaviour was tightened in the rewrite. The common surprises are percentage dimensions in flex containers, position: absolute children of a flex parent, and rows that previously stretched implicitly. These are correctness fixes, but they will look like regressions in a screenshot diff.
  4. Re-measure, do not assume. Startup usually improves noticeably from lazy module init. Steady-state render throughput may not change at all, because your bottleneck was probably JavaScript, not the boundary. Measure before and after with the same trace.

The one-paragraph version

The bridge was a queue, and queues have latency and backpressure. JSI replaced it with a function call, Fabric replaced the mutable view tree with an immutable one that can be built off the main thread, TurboModules made native interfaces typed and lazy, and Nitro made the call itself almost free.

Which means the interesting performance question in React Native has moved. It is no longer "how do I avoid the bridge?" It is the same question as on the web: what is your JavaScript doing, and how much of it needs to happen before the next frame?


Sources: Callstack — Bridgeless native development, FlashList v2 migration guide, react-native-nitro-modules

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.