Illustrated technology atlas · React Native and Expo JS/native interoperability on Android and iOS
React Native + Expo: across the JS/native boundary
Best practices
For software engineers. New Architecture, Hermes, Fabric, TurboModules and Expo Modules; Android/iOS call paths, threading, lifecycle, development builds and update compatibility. Conceptual API examples; no pinned SDK version or benchmark claims. Legacy bridge is historical context.
Research date: 2026-10-08
Part 1 · Fundamentals
One app, three layers
Prerequisites: Start here / basic technical literacy
One app, three layers — mechanism
Conceptual layers. The module path is distinct from rendering; all arrows are interface relationships, not guaranteed threads. [S01][S03][S06][S10]
Mental model verified
Your TS/React logic executes in a JS engine, usually Hermes. React Native hosts it inside an Android or iOS app. C++ infrastructure and platform-specific code connect the JS runtime to native APIs and views. Expo is a toolkit and module layer on this architecture, not an additional rendering engine.
A camera screen has React state and JSX in JS, a native camera view on Android/iOS, and an Expo or third-party module that exposes camera methods and events to JS. Permission prompts and camera capture live in platform code.
First look for a maintained native library. Write a small module only for the unsupported platform capability. Audit where data is copied and what thread owns it.
Do not call every native interaction “the bridge.” Trace a single button press through JS, module or renderer, and platform API; identify what returns synchronously and what returns later.
Keep this: React Native is a JS application coordinated with a C++ renderer and platform code; Expo supplies a higher-level module and build workflow.
Check yourself: Where does Hermes run, and does Expo add another JS runtime?
Hermes runs the application JavaScript. Expo modules expose native capabilities into that same React Native app; Expo is not a second application JS runtime.
Part 2 · Fundamentals
What JSI changed
Prerequisites: 01-three-layers
What JSI changed — mechanism
Two conceptual interop paths; the lower path removes mandatory bridge serialization, not every copy or scheduling cost. [S01][S07]
Mental model verified
The legacy bridge queued serialized messages between JS and native. In the New Architecture, JSI lets JS interact with C++ host objects through an engine-independent interface. Native module frameworks build typed or declarative APIs above it.
A synchronous getCachedValue() may return a small cached scalar. A synchronous readHugeFile() would block the JS caller until I/O finishes; put I/O in an asynchronous function. This is an illustrative design example.
Keep synchronous APIs bounded and predictable. Pass identifiers or handles for large native assets where a library supports them; measure conversion and end-to-end latency rather than guessing from call count.
“Zero-copy everywhere” is false: JSI permits native references but APIs can still convert or copy values. Profile the real path and check ownership and lifetime of referenced native data.
Keep this: JSI allows direct runtime bindings and native object references, but synchronous calls still consume caller time.
Check yourself: Does “no old bridge” mean there is no boundary cost?
No. JSI avoids mandatory JSON-style message serialization, yet conversion, allocation, locks, scheduling and native work can still cost time.
Part 3 · Fundamentals
Fabric: from JSX to pixels
Prerequisites: 01-three-layers
Fabric: from JSX to pixels — mechanism
The common rendering path. Scheduling can differ; the final host-view mutation belongs on the UI thread. [S02][S03][S04]
Mental model verified
Fabric is the React Native renderer. It builds an immutable C++ shadow tree for host components, calculates layout, diffs the next tree, and mounts host views. Render and commit may be scheduled in several ways; the UI thread owns host view manipulation.
Changing a card title creates affected React elements and shadow nodes, then commits layout and applies a host view update. The native control is not rebuilt from scratch for every React render.
Use normal declarative props for view state. A native view is justified when a platform SDK owns a complex surface, such as a map or camera preview. Keep events and props small and well typed.
Do not equate React render with immediate on-screen paint. Draw the render → commit → mount phases and inspect a slow frame for JS work, layout, or UI-thread mount cost.
Keep this: Render creates a C++ shadow tree, commit computes layout, and mount changes host views on the UI thread.
Check yourself: What thread can actually change an Android View or iOS UIView?
The UI/main thread performs the host view mutation during mount.
Part 4 · Applications
TurboModule: typed service API
Prerequisites: 02-jsi-versus-bridge
TurboModule: typed service API — mechanism
A nonvisual native service contract. Language details and generated glue vary by module implementation. [S05][S11]
Mental model verified
A TurboModule exposes functions and events. A TypeScript or Flow spec describes types, Codegen emits platform glue, and native Android/iOS implementations fulfill that contract. Fabric components handle native views separately.
Define getBatteryLevel(): Promise<number> in a typed module spec, implement the platform query separately in Kotlin and Swift/Objective-C++, and handle rejection or missing capability in JS. This is pseudocode for contract shape, not copy-paste setup.
Choose TurboModule when you need RN Codegen contracts or lower-level C++ integration. Keep the exported surface small and versioned; errors and nullability belong in the API design.
Keep this: A typed JS specification and Codegen tie a native service contract to Android and iOS implementations.
Check yourself: Is a TurboModule the path for custom native visual components?
Use a native component/Fabric view for visual surfaces; a TurboModule exposes nonvisual native functions and events.
Part 5 · Applications
Expo Modules: one API, two platforms
Prerequisites: 02-jsi-versus-bridge
Expo Modules: one API, two platforms — mechanism
The same JS interface leads to separately implemented platform behavior; Expo is an abstraction over the RN boundary. [S06][S07]
Mental model verified
Expo Modules API abstracts JSI and related RN primitives. A module definition names the JS-visible surface. Function executes synchronously on the JS runtime thread; AsyncFunction returns a Promise and by default dispatches native work away from that thread.
A biometric SDK wrapper could expose isAvailable as a small cached query and authenticateAsync as an asynchronous operation. Swift and Kotlin implement the same JS-facing intent while each platform handles its own permission and result behavior.
Prefer the Expo DSL for a Swift/Kotlin integration when its API fits. Use a TurboModule when the module needs direct C++ access or RN Codegen contracts. Profile before attributing bottlenecks to either framework.
AsyncFunction does not mean “runs on the UI thread.” Explicitly choose the main queue for UI work; view-bound async methods have their own main-thread behavior. Define failure and cancellation semantics.
Queue ownership for an illustrative operation; the exact dispatch path depends on the module API and implementation. [S02][S07][S12]
Mental model verified
The JS thread runs application logic and usually render work; the UI thread alone manipulates host views. React Native may schedule some rendering on the UI thread. Expo Function runs on JS; AsyncFunction can dispatch to a native queue, with .runOnQueue(.main) when UI access is required.
For a document picker: start platform UI on main, perform file reading away from main, resolve the Promise with a URI and metadata, then update React state on JS. Avoid sending the whole file through a JS value when a URI suffices.
Bound synchronous work, use async for I/O, and isolate native state touched from multiple queues. Keep main-thread sections short and check cancellation when an activity or view goes away.
A Promise can still hide blocking native work if its implementation uses the main queue for CPU-heavy processing. Trace queue hops with profiling and measure both JS and UI frames in a release build.
Event lifecycle and data flow. Aggregation and event ordering must be designed for the particular producer. [S07][S11]
Mental model verified
Native modules can emit events to JS; Expo Modules provide Events, OnStartObserving and OnStopObserving. The native producer may keep working after a screen disappears unless listener and lifecycle cleanup are explicit.
A location feed begins when the first listener subscribes, emits compact coordinates at a useful cadence, and stops on the final unsubscribe. When the app backgrounds, decide whether collection should pause based on product requirements and platform rules.
Coalesce high-frequency telemetry, send bounded payloads, and define ordering, missing-event and resubscription behavior. Use native processing when each sample need not reach JS.
Sending every sensor sample to React state can overwhelm rendering. Measure event rate and JS frame drops; reduce frequency or aggregate natively when the UI only needs a summary.
Keep this: Events are a subscription with cleanup and throughput policy, not an unlimited stream of JS callbacks.
Check yourself: What should happen when the final JS listener is removed?
Stop the native observation or release resources if no other owner needs them.
Part 8 · Best practices
The native binary is an API contract
Prerequisites: 05-expo-module-contract
The native binary is an API contract — mechanism
Build-time native surface and update-time JS surface. An OTA update cannot add a new compiled native module. [S08][S09]
Mental model verified
Expo Go contains a fixed native library set. A development build includes your chosen native packages. CNG generates Android and iOS projects from config; config plugins describe persistent changes to native project files. A runtimeVersion links OTA updates to compatible binaries.
Adding a native payment SDK changes the binary. Install it, configure its plugin, create and test new Android/iOS development and production builds, then publish JS updates only to the matching runtime.
Treat every native dependency/config change as a release compatibility event. Use a runtimeVersion policy and test a preview build with the same native runtime before rollout.
Editing generated AndroidManifest.xml or Info.plist directly in a CNG workflow can be lost on prebuild. Encode changes in config plugins and check generated projects on both platforms.
A measurement loop, not a claim that native code is inherently faster for a particular workload. [S01][S10][S12]
Mental model verified
A slow screen may be caused by JS computation, too many renders, layout, UI-thread work, image decoding or native callback volume. JSI reduces one class of overhead but is not a universal performance fix. Hermes behavior and profiling should be judged in release builds.
If a camera overlay stutters, record JS and UI frame behavior, event frequency and payload size. Try lower event cadence or native aggregation first; compare the same device and scenario after each change.
Set a target interaction and trace a real flow. Change one bottleneck at a time; record median and tail latency, dropped frames and memory on representative Android and iOS devices. These are proposed measurements, not claimed results.
A microbenchmark for module calls can miss image conversion, native SDK work and React rerender cost. Validate the user-visible result and watch for regressions in startup, battery or memory.
The public API can be shared; platform lifecycle and capability checks remain separate. [S07][S08][S09]
Mental model verified
An API can present the same JS method name on both platforms while Android Activity lifecycle and iOS app lifecycle hooks differ. A good abstraction states common behavior and surfaces capability differences explicitly.
For a secure-document scan wrapper, define a discriminated result for success, user cancellation, denied permission and unsupported capability. Implement dismissal/cleanup separately for Android and iOS, then test each path on devices.
Keep the TS facade stable; make platform-specific native tests and contract tests for returned shapes. Version the binary and JS API together when native behavior changes.
A simulator success can hide hardware permission and lifecycle problems. Add device checks for background/foreground, rotation or activity recreation where relevant, and app termination.