React Native architecture: threads, Hermes, JSI and Fabric
How does a React state update in JavaScript become a real UIView or Android View, and which thread does each step run on?
React Native runs your React code in Hermes on a JS thread, builds an immutable C++ shadow tree, lays it out with Yoga on a background thread and mounts the diff onto real platform views on the UI thread. Since 0.82 the New Architecture (JSI, Fabric, TurboModules, bridgeless) is the only architecture; there is no async JSON bridge any more.
THE MENTAL MODEL
Picture three workers sharing one C++ core. The JS thread runs React: your components, state and event handlers, compiled to Hermes bytecode in release builds. The renderer, Fabric, is C++ that React calls directly through JSI (no serialization), so render produces a new immutable shadow tree, a background thread computes layout with Yoga, and the UI thread applies only the resulting mutations to UIKit or Android views. Native capabilities are host objects exposed over the same JSI: TurboModules typed by a Codegen spec, or Expo Modules written in Swift and Kotlin. Everything that makes an app feel slow comes from one rule: the UI thread must never wait, and the JS thread can only do one thing at a time. Animations and gestures that must track the finger therefore run as worklets on the UI thread, long lists recycle native cells, and anything heavy moves off the JS thread.
HOW IT FITS TOGETHER
- Your app (JS/TS bundle)Components, state, navigation, business logic. Metro bundles it; release builds ship Hermes bytecode (.hbc) instead of source.
- React (reconciler)Render phase on the JS thread: diffs elements and emits host components (View, Text, Image). Concurrent features and React 19.x behave as on the web.
- Fabric renderer (C++)Immutable shadow tree, Yoga layout on a background thread, tree diffing and view flattening. Shared by iOS and Android.
- JSI + Hermes runtimeC++ interface that lets JS hold references to native host objects and call them synchronously. TurboModules, Expo Modules and Fabric all sit on it.
- Native platformUIKit or Android Views mounted on the UI (main) thread, plus platform services such as camera, push, storage and sensors.
- UI / main thread → JS thread (Hermes)Native touch dispatched as a press eventSince 0.88 events use the W3C EventTarget model by default: capture down to the target, then bubble back up.
- JS thread (Hermes) → JS thread (Hermes)onPress runs, setState schedules a renderWhile this handler runs, the JS thread can do nothing else: no other events, no JS-driven animation frames.
- JS thread (Hermes) → Fabric core (C++)Render: clone changed shadow nodes via JSIOnly nodes on the path to the change are cloned; unchanged subtrees are shared (structural sharing). Composite components get no shadow node.
- Fabric core (C++) → Background threadCommit: hand off the new tree
- Background thread → Fabric core (C++)Yoga layout done, promoted to next treeText sizing is delegated to the platform because text measurement is platform-specific.
- Fabric core (C++) → UI / main threadMount: diff + flatten into mutationsScheduled for the next UI tick. A tree of roughly 600-1000 shadow nodes typically flattens to about 200 host views.
- UI / main thread → UI / main threadApply UpdateView(tag, props) to the viewThe only step that touches UIView or android.view.View, and the only thread allowed to.
KEY TERMS
- Threads
- The UI (main) thread owns host views and touch input. The JS thread runs React render and your handlers. A background thread runs layout and commit. A frame is lost when the UI thread is blocked or when JS-driven work misses the frame deadline; a busy JS thread alone freezes JS logic but native scrolling and UI-thread animations keep running.
- Hermes
- The default engine, bundled per React Native release. Hermes V1 became the default on iOS and Android in 0.84. Release builds compile JavaScript to bytecode at build time, which is what makes startup fast; `global.HermesInternal` tells you Hermes is running, not that you are loading precompiled bytecode.
- JSI
- A C++ API between the JS engine and native code. JS can hold references to C++ host objects and call their methods synchronously, without the old bridge's JSON serialization and batching. Synchronous is a capability, not a default: a slow sync call blocks the JS thread.
- Fabric
- The C++ renderer: render (JS thread) builds an immutable shadow tree, commit (background) runs Yoga layout and promotes the tree, mount (UI thread) diffs, flattens and applies mutations. Immutability is what lets it be thread-safe and lets high-priority updates run synchronously on the UI thread.
- TurboModules and Codegen
- Native modules loaded lazily over JSI. You write a typed spec (TypeScript or Flow), and Codegen generates the C++/ObjC++/Java glue and native interfaces at build time, so type mismatches fail the build instead of crashing at runtime. 0.88 maps ArrayBuffer for Java TurboModules, so binary data no longer needs Base64.
- New Architecture only, bridgeless
- Since 0.82 `newArchEnabled=false` and `RCT_NEW_ARCH_ENABLED=0` are ignored; 0.81 and Expo SDK 54 were the last releases with the Legacy Architecture. 0.84 stopped shipping legacy code in iOS builds. An interop layer still lets many old-style libraries run, but it is a bridge to the past, not a target.
- Expo, CNG and Expo Modules
- Expo is the framework the React Native docs recommend. "Managed vs bare" is retired terminology: with Continuous Native Generation, `npx expo prebuild` generates the ios/ and android/ folders from app config and config plugins, so they can be gitignored; you can still commit and hand-edit them instead. Expo Modules are a Swift/Kotlin DSL over JSI and work in any React Native app that depends on the expo package. SDK 58 ships React Native 0.88.
IN YOUR STACK
TypeScript · Turbo Native Module + Codegen Client
// specs/NativeLocalStorage.ts: Codegen reads this file at build time
import type { TurboModule } from "react-native";
import { TurboModuleRegistry } from "react-native";
export interface Spec extends TurboModule {
setItem(value: string, key: string): void;
getItem(key: string): string | null; // sync over JSI: keep it O(1)
removeItem(key: string): void;
clear(): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>("NativeLocalStorage");
// package.json
// "codegenConfig": {
// "name": "NativeLocalStorageSpec",
// "type": "modules",
// "jsSrcsDir": "specs",
// "android": { "javaPackageName": "com.nativelocalstorage" },
// "ios": { "modulesProvider": { "NativeLocalStorage": "RCTNativeLocalStorage" } }
// }
// App.tsx
import NativeLocalStorage from "./specs/NativeLocalStorage";
const draft = NativeLocalStorage.getItem("draft");- Codegen generates a NativeLocalStorageSpec base class on Android (extend it in Kotlin or Java and register it in a BaseReactPackage) and a protocol plus NativeLocalStorageSpecJSI on iOS (implement it in an Objective-C++ class).
- Return a Promise<T> from the spec for anything that touches disk or network; a sync method holds the JS thread until native returns.
- getEnforcing throws at startup if the native side is missing, which is better than a silent undefined later.
Swift · Expo Modules API (iOS) Client
import ExpoModulesCore
public class PowerModule: Module {
private var observer: NSObjectProtocol?
public func definition() -> ModuleDefinition {
Name("Power")
Events("onLowPowerModeChange")
// Sync: returns directly through JSI on the JS thread. Keep it trivial.
Function("isLowPowerMode") { () -> Bool in
ProcessInfo.processInfo.isLowPowerModeEnabled
}
// Async: runs off the JS thread and resolves a Promise.
AsyncFunction("thermalState") { () -> Int in
ProcessInfo.processInfo.thermalState.rawValue
}
OnStartObserving {
self.observer = NotificationCenter.default.addObserver(
forName: .NSProcessInfoPowerStateDidChange, object: nil, queue: nil
) { [weak self] _ in
self?.sendEvent("onLowPowerModeChange", ["enabled": ProcessInfo.processInfo.isLowPowerModeEnabled])
}
}
OnStopObserving { if let o = self.observer { NotificationCenter.default.removeObserver(o) } }
}
}- From JS: `const Power = requireNativeModule("Power")`, then `Power.isLowPowerMode()`, `await Power.thermalState()` and `Power.addListener("onLowPowerModeChange", cb)`.
- Argument and return types are converted for you (records, enums, typed arrays); no Codegen step and no Objective-C++.
- SDK 58 introduced Expo Modules 2.0 in beta, which exposes annotated plain Swift/Kotlin classes; the ModuleDefinition DSL shown here is the stable API.
Kotlin · Expo Modules API (Android) Client
package expo.modules.power
import android.content.Context
import android.os.Build
import android.os.PowerManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PowerModule : Module() {
private val powerManager: PowerManager
get() = appContext.reactContext!!.getSystemService(Context.POWER_SERVICE) as PowerManager
override fun definition() = ModuleDefinition {
Name("Power") // same name as iOS: one JS API, two implementations
Function("isLowPowerMode") {
powerManager.isPowerSaveMode
}
AsyncFunction("thermalState") {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) powerManager.currentThermalStatus else -1
}
}
}- List the module class in expo-module.config.json (`android.modules` / `apple.modules`); autolinking registers it, so there is no package class to wire up.
- Scaffold with `npx create-expo-module --local` to keep a module inside the app repo instead of publishing a package.
- Use Expo Modules when you want Swift/Kotlin ergonomics; use a Turbo Native Module when you need a C++ core or must avoid depending on the expo package.
- Keep the two sides' contracts identical: iOS thermalState is 0-3 while Android thermal status is 0-6, and this Android version emits no onLowPowerModeChange event, so map values to one shared enum before JS sees them.
TypeScript · Reanimated 4 + worklets Client
import { Pressable } from "react-native";
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated";
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
export function LikeButton({ onLiked }: { onLiked: () => void }) {
const scale = useSharedValue(1); // readable and writable from both runtimes
// A worklet: re-evaluated on the UI thread whenever scale changes.
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] }));
const pop = () => {
"worklet";
scale.value = withSpring(1.3, {}, (finished) => {
scale.value = withSpring(1);
if (finished) scheduleOnRN(onLiked); // hop back to the JS thread
});
};
return (
<Pressable onPress={() => scheduleOnUI(pop)} accessibilityRole="button" accessibilityLabel="Like">
<Animated.View style={[{ width: 48, height: 48, borderRadius: 24, backgroundColor: "tomato" }, style]} />
</Pressable>
);
}- Once started, the spring runs frame by frame on the UI thread, so it stays smooth even while the JS thread is busy rendering a screen.
- The press itself still arrives on the JS thread. For finger-tracking gestures use react-native-gesture-handler, whose callbacks are worklets too, so no JS round trip happens at all.
- Reanimated 4 runs only on the New Architecture and needs the react-native-worklets package; Expo includes the Babel plugin, bare CLI apps add 'react-native-worklets/plugin' last.
TypeScript · Web Performance APIs (0.83+) Client
// Globals in React Native 0.83+, visible in the DevTools Performance panel
// and observable at runtime, including in release builds.
declare function report(metric: { type: string; name: string; ms: number }): void;
declare const api: { getFeed(): Promise<unknown[]> };
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === "longtask" || entry.duration > 100) {
report({ type: entry.entryType, name: entry.name, ms: Math.round(entry.duration) });
}
}
});
observer.observe({ entryTypes: ["measure", "event", "longtask"] });
export async function loadFeed() {
performance.mark("feed:start");
const items = await api.getFeed();
performance.mark("feed:fetched");
performance.measure("feed:fetch", "feed:start", "feed:fetched");
return items;
}
// Non-standard: native init, bundle start and runtime-ready timestamps
const { startTime, executeJavaScriptBundleEntryPointStart, endTime } = performance.rnStartupTiming;- React Native DevTools (desktop app, no Chrome needed since 0.83) shows JS execution, React performance tracks, network events and these user timings on one timeline; the React Profiler panel shows per-commit render cost.
- `event` entries come from the Event Timing API: a slow one means input waited on a busy JS thread. `longtask` entries point at what kept it busy.
- performance.now() and timeOrigin are measured from system boot, not app start, so compare entries with each other rather than with wall-clock time. Always profile release builds.
TypeScript · FlashList v2 Client
import { FlashList, useRecyclingState } from "@shopify/flash-list";
import { Text } from "react-native";
type Row = { id: string; kind: "header" | "message"; text: string };
function MessageRow({ item }: { item: Row }) {
// Cells are recycled: plain useState would leak "expanded" into the next row drawn here.
const [expanded, setExpanded] = useRecyclingState(false, [item.id]);
return <Text numberOfLines={expanded ? undefined : 2} onPress={() => setExpanded((e) => !e)}>{item.text}</Text>;
}
export function Inbox({ rows }: { rows: Row[] }) {
return (
<FlashList
data={rows}
keyExtractor={(row) => row.id}
getItemType={(row) => row.kind} // separate recycle pools per layout
renderItem={({ item }) =>
item.kind === "header" ? <Text accessibilityRole="header">{item.text}</Text> : <MessageRow item={item} />
}
/>
);
}- FlashList reuses mounted cells instead of unmounting and remounting them, which is far cheaper than FlatList's virtualization on long or fast-scrolled lists.
- v2 no longer needs estimatedItemSize (it is ignored) and replaces MasonryFlashList with a masonry prop; maintainVisibleContentPosition is on by default for chat-style lists.
- Keep renderItem cheap and getItemType fast: both run on the JS thread for every cell that scrolls into view.
WHEN TO REACH FOR WHAT
Expo with CNG (prebuild)
Use when Most new apps: native folders are generated from config and config plugins, upgrades are an SDK bump, and EAS covers builds, submissions and updates.
Avoid when You must hand-maintain large native code that no config plugin can express; commit the native folders instead, still using Expo libraries.
Committed ios/ and android/ folders
Use when Brownfield apps embedding React Native in an existing native app, or heavy custom native build setup that you own.
Avoid when You only need a few native settings; every upgrade becomes a manual native diff you have to merge.
Expo Module (Swift/Kotlin)
Use when Wrapping a platform API or SDK quickly with idiomatic Swift and Kotlin, events and views, in an app that already depends on expo.
Avoid when The core logic is shared C++, or the library must not depend on the expo package.
Turbo Native Module + Codegen
Use when Library authors targeting every React Native app, C++ cross-platform modules, or when you want a typed spec as the contract.
Avoid when A small app-specific wrapper where ObjC++ glue and Codegen setup cost more than they save.
WHERE IT BITES
- Profiling debug builds: dev mode adds checks and loads source instead of bytecode, so JS can look several times slower than in release. Measure release builds on a low-end Android device.
- Heavy work in event handlers or render (JSON parsing, sorting big arrays, synchronous storage reads) blocks the JS thread; presses queue up and JS-driven animations stall even though native scrolling continues.
- Making native calls synchronous because JSI allows it. Disk, network and keychain access belong in Promise-returning (AsyncFunction) methods.
- Plain useState inside recycled FlashList cells leaks state between rows; key it with useRecyclingState or lift it into the data.
- Upgrading past 0.82 with libraries that use legacy NativeModules or UIManager APIs: they run only through the interop layer, which is not guaranteed forever. Check each dependency's New Architecture support before the upgrade, not after.
- Editing generated ios/ or android/ folders in a CNG project: the next `expo prebuild --clean` or EAS build overwrites them. Put native changes in a config plugin.
CLOSE THE AI. EXPLAIN THIS.
A user taps a button that toggles a row's background color. Name every thread involved, what each one does (handler, render, commit/layout, mount), what JSI replaces from the old bridge, and where a 300 ms synchronous native call in the handler would make the app feel frozen.WHEN IT BREAKS IN PRODUCTION
SOURCES
- React Native: Render, Commit, and Mount (opens in new tab)Checked
- React Native: Threading Model (opens in new tab)Checked
- React Native 0.82: A New Era (New Architecture only) (opens in new tab)Checked
- React Native 0.84: Hermes V1 by Default (opens in new tab)Checked
- React Native 0.88: Web-based event dispatching, ArrayBuffer for Java TurboModules (opens in new tab)Checked
- React Native: global performance (rnStartupTiming, timeOrigin) (opens in new tab)Checked
- Expo: Continuous Native Generation (opens in new tab)Checked
- Expo SDK 58 changelog (opens in new tab)Checked
Explainer reviewed