Target the New Architecture (the only runtime since RN 0.82). Expo Modules for most native needs; Turbo Modules for standard custom native with zero dependencies; Nitro Modules only for performance-critical custom native where you accept pre-1.0 churn. What changes the pick is how chatty the JS↔native boundary is and how much version churn you can absorb.
most native integrations → Expo Modules (managed, fast path)
new Expo module on the SDK 58 beta → Expo Modules 2.0 API (@ExpoModule/@JS; beta, docs still being written, views not covered — else the 1.0 DSL)
perf-critical custom native (camera, audio, BLE) → Nitro Modules (pre-1.0, third-party, not in RN core: pin versions; 0.37 alone moved Props into Nitro core and renamed CachedProp to ReactProp)
still on Legacy Arch → New Architecture migration (Legacy is frozen and stripped from core; budget the long tail, see the Discord reading)
RN >= 0.86 with react-native-edge-to-edge installed → core edge-to-edge (drop the standalone package; below 0.86 it is still required)
Options & tradeoffs
the field considered — and why each one isn’t the default here
option
tradeoff
evidence
Expo modules
Managed, fast path for most native needs, in Swift/Kotlin. SDK 56 made calls 1.5–2× faster than SDK 55 (on par with Turbo Modules); Expo Modules 2.0 (beta on iOS + Android in the SDK 58 beta) is a further 2.5–5.6× on sync iOS calls and beat a TurboModule on all twelve Android microbenchmarks. See note: EXPO MODULES 2.0.
12.0M/wk · ships in 4/14
Turbo Modules / Fabric (JSI)
Custom native on the New Architecture; zero-dep, in RN core and stable, which is why it stays the pick when Nitro's pre-1.0 churn is not worth the speed.
Nitro Modules
Codegen-driven JSI, positioned as Turbo Modules' successor; vendor claims ~10x vs Turbo, ~100x vs Expo Modules — the Expo half is STALE (see note: EXPO MODULES 2.0). Pre-1.0 and third-party: 0.37.1 (2026-08-27, verified vs npm), still shipping breaking changes, so pin versions. See note: NITRO, STEWARDSHIP.
npm weekly downloads (from the corpus's last signals run): react-native-nitro-modules 2.2M · expo-modules-core 12.0M · expo 12.0M
Verified notes
THE PICTURE: the New Architecture (Fabric + Turbo Modules over JSI, no async bridge) is the only runtime, so the choice is which authoring layer sits on JSI. Expo Modules is the default because its call overhead is no longer a tax: Expo's own numbers put it level with Turbo Modules on SDK 56 and ahead of a TurboModule on Android with Expo Modules 2.0. Turbo Modules stay the stable in-core option. Nitro Modules buys the lowest overhead at the price of pre-1.0 churn, which only pays off for chatty, fine-grained boundaries (camera frames, audio, BLE).
ARCHITECTURE (verified vs the RN 0.84 blog): the New Architecture was the default for new projects from RN 0.76, became the ONLY runtime option in RN 0.82, and RN 0.84 keeps stripping Legacy-Arch code from iOS + Android; the bridge interop layer was fully removed in RN 0.85 → all new native work targets it.
EXPO MODULES 2.0 (2026-09-21, verified vs the SDK 58 beta changelog of 2026-09-15; API from the 2026-09-08 Expo blog post): the authoring API becomes annotated plain Swift/Kotlin (@ExpoModule on a class, @JS on methods and properties, @Record on a struct, a typed callable property for events), retiring the 1.0 result-builder DSL (Name/Function/AsyncFunction/Property/Class/Events); sync-vs-async is decided by Swift's own `async` keyword. It is IN BETA ON BOTH iOS AND ANDROID in the SDK 58 beta; documentation is still being written (the early-look post remains the guide) and views are not covered. iOS: on the same SDK the @JS path is 2.5–5.6× faster than 1.0 for SYNCHRONOUS calls (async roughly unchanged; iPhone 16 Pro / iOS 27 / Release, 100k calls) because the macro reads the signature at build time and the 1.0 reflection path disappears. Android: across twelve microbenchmarks 2.0 beat 1.0 in every case (1.2× for an array of 100 numbers up to 39.9× for emitting an event with a number) and beat a TurboModule in every case (no-arg call 112 ns vs 2,837 ns; array of 20 objects 10.7 µs vs 149.8 µs; event with an object 963 ns vs 8,080 ns) → on Android call overhead 2.0 is ahead of Turbo Modules, not level. These are vendor-run call-overhead microbenchmarks: they matter for chatty boundaries and are irrelevant to a module that makes one call and does real work behind it. Migration is incremental (both APIs merge in one module; the `expo-migrate-module` agent skill converts the Swift side and reports what it left behind); Expo's stated rationale is that models know Swift/Kotlin, not a bespoke DSL.
BUILD TIME (2026-09-21, verified vs the SDK 58 beta changelog): expo-modules-core ships PRECOMPILED .so files for all four ABIs in debug and release → clean builds and CI no longer compile its C++; Expo's blank-project clean four-ABI Android debug build on an M3 Pro went ~80s → ~39s, with a larger saving on low-core CI runners.
EXPO SDK 56 (2026-07-09, verified vs its changelog): expo-modules-core dropped the Objective-C++ intermediary for DIRECT Swift↔C++ JSI interop ("fewer hops means less call overhead"), making calls 1.5–2× faster than SDK 55 and, per Expo, "on par with React Native's Turbo Modules"; SDK 56 also allows inline Expo Modules (Swift/Kotlin in-app with generated TypeScript). Expo Modules ships SHARED OBJECTS/REFS (verified vs docs.expo.dev/modules/shared-objects): JS handles to live native objects instead of serialized copies; the next-gen MediaLibrary/Contacts APIs use them.
NITRO (verified vs the v0.37.0 release and npm): v0.37.0 (2026-08-20) moved Props and react::ComponentDescriptor into Nitro core via C++ templates and renamed CachedProp to ReactProp (old name kept as a deprecated alias) → the churn is real; this entry's tripwire fires at 1.0. Nitro is consolidating custom native: mmkv v4, device-info, image, video, fetch, healthkit and geolocation moved to it, and VisionCamera v5 rewrote ~3,000 lines of JSI/C++ onto it.
STEWARDSHIP (announced 2026-09-01, verified 2026-09-21 vs Margelo's announcement): Margelo, the agency behind Nitro Modules, VisionCamera and react-native-mmkv, joined Callstack in a transaction valuing Margelo at more than €20 million; the existing open-source libraries remain open source and Marc Rousavy continues to lead them → continuity, not a handover risk; the "small independent vendor" half of Nitro's risk shrank, the pre-1.0 half did not. Contrast RB-E-ANIMATION (Skia) and RB-E-LISTS (FlashList), where Shopify is leaving; RB-E-STYLING owns Tailwind into Shopify.
EDGE-TO-EDGE (verified vs the RN 0.86 blog): RN 0.86 (2026-06-11) moved Android-15+ edge-to-edge into core, fixing KeyboardAvoidingView / Dimensions / StatusBar → on RN >= 0.86 drop the standalone react-native-edge-to-edge (zoontek); below 0.86 it is still required.
LEAD (2026-07-16 back-audit; TWiR #284 and #285): margelo/react-native-runtimes runs RN components/business logic in ISOLATED Hermes runtimes; young, not an option until adoption shows.
HISTORY: 2026-09-24 CORRECTED: the Expo Modules 2.0 reading claim still said iOS-only and undocumented in SDK 57. 2026-09-21 REOPENED: Expo Modules 2.0 in beta on iOS and Android (SDK 58 beta), measured ahead of a TurboModule on Android call overhead; precompiled expo-modules-core halves clean Android builds; Margelo joined Callstack. 2026-09-10 REOPENED: Expo Modules 2.0 early look (iOS-only, undocumented at the time). 2026-07-09 REOPENED: Expo SDK 56 direct Swift↔C++ JSI interop, inline modules. 2026-06-25 CHALLENGED → SURVIVES: adversarial stress test; Nitro stability caveat added (pre-1.0, third-party; Turbo Modules stay the stable in-core option).
Canonical reading
Editorial annotations on why each piece matters — the articles themselves are the originals; read them there.
A native-module authoring rule extracted from a real op-sqlite crash, and it invalidates an assumption most C++ modules were written under. Under the old bridge a reload was strictly sequential — the outgoing runtime's invalidate() completed before the next generation's install() ran — so process-global module state (a static shared_ptr, a bool flag, a cached CallInvoker) was safe by construction, because only one generation could ever touch it. Bridgeless starts the new RCTInstance BEFORE the old one has finished invalidating and gives the old one only a few seconds to drain, so two runtime generations are genuinely alive at once. The crash: an in-flight SQLite query on a background thread is interrupted by a reload or OTA update and tries to schedule its error back onto the JS thread, by which time the new generation has already overwritten the global invoker — so the callback either runs on the wrong invoker or destroys JS objects off the JS thread. The fix generalizes past SQLite: give each generation its own liveness flag (a shared_ptr<atomic<bool>> that install() allocates and invalidate() flips for THAT generation only), and have every host object COPY the invoker and the liveness pointer into members at construction rather than reading the globals inside the callback — bind generation identity at creation, check it at run time. Fixed every update hook at once with no call-site changes.
The post-flip half of a New-Architecture migration, which the official docs don't cover, told through one app at Discord's scale (Android first, then iOS). The headline is the ticket breakdown on the way back to parity: rendering/layout/visual 47%, crashes & stability 16%, build/infra/the migration ITSELF 14%, performance 13%, input (keyboard/gesture) 11% — i.e. flipping the switch is about one seventh of the work, and the rest is the app behaving differently under the new renderer. Three broken implicit contracts, each worth knowing before you hit it: (1) MEASUREMENT ORIGIN — old arch measured a view against the window, Fabric measures against its Yoga root, and a modal is its own root, so the same measure() returns different numbers inside a modal; the tell is a constant offset that looks like the height of something else. (2) VIEW IDENTITY — Fabric's view flattening is re-evaluated on every commit, and accessibility props are among those that force a real host view, so toggling accessibilityElementsHidden mid-gesture can swap the gesture's backing view and cancel it silently (onFinalize never runs); collapsable={false} pins it. (3) NATIVE LOOKUP — the legacy-interop layer finds a view manager by appending 'Manager' to the component name; a class named without that suffix misses the fast path, falls back to a main-thread-synchronous module creation while holding a registry write lock, and deadlocks against an in-flight animation's read lock. The advice that generalizes: budget for INVESTIGATION, not fixes (every story above ended in one prop, one helper, or a rename — the cost was finding it); the problems cluster where your app touches native (measurements, refs, gestures, imperative calls, interop-layer components) while pure React code comes through untouched; instrument before optimizing; and fix upstream rather than patch locally, because a local patch is tech debt every future upgrade must re-check.
The first-party walkthrough of the API change behind this entry's Expo Modules 2.0 note: each 1.0 DSL component folded into ordinary Swift (Function/AsyncFunction → a method marked @JS, Property → a var, a record → a struct, an event → one typed callable property), with the migration path (both APIs coexist in one module; the expo-migrate-module agent skill does the Swift side and lists what it couldn't). Also states the mechanism behind the speedup — build-time signatures replace 1.0's per-call reflection — and what is NOT done yet: views, Android, generated TypeScript declarations, and documentation.
How JSI bindings actually work at the C++/Hermes boundary — HostFunction vs HostObject, the Object+NativeState pattern Nitro compiles down to, stack vs heap, minimizing JS-to-native crossings — with reproducible Hermes benchmarks. The runtime mechanics under Turbo/Nitro Modules.
How data representation across the JSI boundary (array-of-objects vs flat array vs ArrayBuffer, numeric vs string contracts) can swing performance up to ~30x with no algorithmic change. First-principles guidance for New-Architecture native modules.
A four-year native-library architecture case study: wrapping OpenSSL for Node behavior-parity, a sync-first threading model with async escape hatches, the hand-written-JSI to Nitro codegen migration, and security audit as architecture validation. Generalizable lessons for native-module authors.
The memory-leak sibling of the Discord jank postmortem above: Fabric ShadowNodes are C++ objects retained via JS wrapper objects, so unmounted views' wrappers can pin whole shadow-tree revisions (amplified by Reanimated's per-frame revision cloning) — and none of it appears in Hermes heap snapshots, because the retained memory sits outside the JS heap. The author's two candidate mitigations (periodic forced GC; per-wrapper setExternalMemoryPressure) are both judged not production-ready — this is debugging awareness, not a shipped fix. (2026-08-05)
Watching
Curated talks, podcasts & videos — the A/V companion to the reading list.
37-min episode (2026-01) from Nitro's author on why Nitro exists (JSI boilerplate), how it compares to Turbo Modules and Expo Modules, and the ecosystem's package-migration wave — the audio companion to this entry's Nitro-consolidation note.
Depth (in-domain rules) is owned by the react-native-jsi skill — this entry is selection breadth.
The full explanation
The reviewed long-form essay behind this entry — the why, not a how-to. Also on GitHub.
About native modules & the New Architecture (React Native)
Diataxis: Explanation. This page builds understanding of how RN talks to native code
and which module path to choose. It is not a tutorial: the candidate list lives in the
index entry RB-E-NATIVE; JSI memory-ownership and threading rules are owned by the
react-native-jsi skill. Read this for the why; read that skill to do it safely.
The shift that changed everything: the bridge is gone
For years React Native's JS and native sides talked over an asynchronous bridge that
serialised every call to JSON. That bridge was the source of most "RN is slow at the boundary"
pain: batched, async, copy-heavy. The New Architecture removes it. JS and native now share
a C++ layer — JSI (the JavaScript Interface) — and call each other synchronously,
without serialization. On top of JSI sit Fabric (the renderer, with an immutable C++
shadow tree) and Turbo Modules (native modules over JSI). This is the single fact that
organises every native decision: there is no async bridge anymore; native is a synchronous,
C++-mediated call away.
Two consequences: native calls can be synchronous and cheap, and a whole class of native
libraries can be rebuilt on JSI for large speedups. The cost is that the boundary is now
real C++ — memory ownership and threading matter (which is why depth defers to
react-native-jsi).
This is not optional anymore
The New Architecture is the architecture. It became the default for new projects in
RN 0.76, the only runtime option in RN 0.82 (opt-out ignored), and RN 0.84
began stripping Legacy-Architecture code from iOS and Android. Legacy was frozen at 0.80 and
is effectively gone. So "should we migrate to the New Architecture?" is no longer a question —
all new native work targets it, and apps still on Legacy are on borrowed time
(RB-E-RN-VERSIONS). (Verified against the official RN 0.84 and 0.86 release blogs.) One
dependency falls out of this too: RN 0.86 moved Android-15+ edge-to-edge into core, so on RN
0.86+ the standalone react-native-edge-to-edge can be dropped; below 0.86 it is still needed.
The default, and why
Target the New Architecture (the only runtime since RN 0.82). Expo Modules for most native
needs; Turbo Modules for standard custom native with zero dependencies; Nitro Modules only for
performance-critical custom native where you accept pre-1.0 churn.
The selection is about how much native you're writing and how hot the path is. Expo
Modules is the managed fast-path: a clean Swift/Kotlin authoring model that covers the vast
majority of integrations with the least ceremony — the right default even in bare RN. When you
need a custom native module on a hot path (camera frames, audio, BLE, crypto), Nitro
Modules — codegen-driven JSI, positioned as Turbo Modules' successor — is the performance
choice and is consolidating the ecosystem (mmkv v4, device-info, image, video, fetch, and
VisionCamera v5 all moved onto it). Turbo Modules / Fabric directly is the standard path
when you're writing conventional custom native and don't need Nitro's edge.
Two facts moved this default in 2026. First, Expo Modules stopped being the slow option: SDK 56
dropped the Objective-C++ hop for direct Swift↔C++ JSI interop, making calls 1.5–2× faster and,
per Expo, on par with Turbo Modules; Expo Modules 2.0 (annotated plain Swift/Kotlin —
@ExpoModule, @JS, @Record — in beta on iOS and Android in the SDK 58 beta, docs still being
written) is faster again, and on Android it beat a TurboModule on all twelve of Expo's
call-overhead microbenchmarks. Those are vendor microbenchmarks of the boundary: they matter for
chatty modules, not for one call that does real work. Second, Nitro is still pre-1.0 and
third-party (0.37.x; 0.37.0 alone moved Props into Nitro core and renamed CachedProp to
ReactProp), so pin its version; Turbo Modules stay the stable in-core option when that churn
is not worth the speed. Its stewardship is steady: Margelo, which builds Nitro, VisionCamera and
react-native-mmkv, joined Callstack (announced 2026-09-01), with the libraries staying open
source and Marc Rousavy still leading them.
The landscape, and when each one wins
Expo Modules — managed, ergonomic, broad coverage; the default for most native needs. You
write idiomatic Swift/Kotlin and get a clean JS surface. Not Expo-app-only — usable in bare RN.
Turbo Modules / Fabric (JSI) — the New Architecture's own module and rendering system.
The baseline for custom native work; you're writing against codegen and JSI directly.
Nitro Modules — codegen JSI with an emphasis on raw performance and ergonomics; the
ecosystem's consolidation point for perf-critical native. Reach for it when the boundary is the
bottleneck — high-frequency calls, large buffers (ArrayBuffers to avoid serialization),
real-time pipelines. The durable mechanics of why JSI code is fast (HostObject vs
HostFunction, data-shape choices) are worth understanding — see the reading.
Tradeoffs and failure modes to name out loud
Hand-writing JSI you didn't need to. Most "I need native" cases are an Expo Module, not
raw JSI. Drop to Turbo/Nitro/JSI only when a managed module genuinely can't do it or the
path is hot.
Ignoring the C++ ownership model. With the bridge gone, the boundary is real memory and
real threads; lifetime bugs and cross-runtime object passing are the new failure class.
This is exactly what react-native-jsi owns — route there before shipping custom native.
Treating "New Arch migration" as optional. It isn't (mandatory 0.82, stripped 0.84).
Budgeting it as a someday-task on a pre-0.82 app is a planning error (RB-E-RN-VERSIONS).
Serializing across a non-serialized boundary. A common Nitro/JSI lesson: passing big
arrays-of-objects or strings where an ArrayBuffer/typed contract would do can swamp the
gains. Data shape across the boundary is a first-order performance decision.
How it interacts with the rest of the stack
RN versions (RB-E-RN-VERSIONS). The New Architecture's mandatory/stripped timeline is
the reason to track RN versions; this entry explains the architecture those versions ship.
Media (RB-E-MEDIA) & storage (RB-E-STORAGE). The flagship Nitro/JSI consumers:
VisionCamera v5 (frame processors), MMKV v4, op-sqlite. Those entries are the domain; this
one is the module system underneath.
Build (RB-E-BUILD). Codegen, precompiled iOS binaries, and Hermes V1 are the build-time
side of the New Architecture.
JSI depth (react-native-jsi). Memory ownership, threading, and JSI API correctness live
there — this page tells you which module path, that skill tells you how to not crash.
In one paragraph
The New Architecture deleted the async bridge: JS and native now share a synchronous C++ layer
(JSI), with Fabric rendering and Turbo/Nitro Modules for native code. It's
mandatory from RN 0.82 and Legacy is being stripped, so all new native work targets it. Choose
by how much native you're writing: Expo Modules for most needs (managed, even in bare RN),
Nitro Modules for performance-critical custom native (it's consolidating the ecosystem),
Turbo Modules/Fabric for standard custom work — and push JSI memory/threading correctness
down to react-native-jsi, because the boundary is now real C++.
See also: RB-E-RN-VERSIONS (the mandatory-migration timeline), RB-E-MEDIA / RB-E-STORAGE
(flagship Nitro/JSI consumers), RB-E-BUILD (codegen, Hermes V1, precompiled iOS). JSI
memory/threading depth: the react-native-jsi skill.