Illustrated technology atlas · React Native and Expo internals on Android and iOS
Inside React Native: Hermes, Fabric, Modules and Native APIs
Best practices
Second volume to the React Native + Expo JS/native boundary illustrated pack. Engineer-focused deeper mechanisms in Hermes, Fabric, TurboModules, Expo Modules, Kotlin and Swift platform APIs. Android/iOS thread, ownership, lifecycle, release and diagnosis practices. Conceptual examples; no unverified performance benchmarks or exhaustive SDK tutorial.
Research date: 2026-10-09
Part 1 · Fundamentals
Hermes: build-time bytecode to a running JS heap
Prerequisites: Start here / basic technical literacy
Build and launch path
Release-build path; dev builds and OTA delivery can use different packaging steps. [S01][S02]
Mental model verified
Metro prepares the JS bundle, the Hermes compiler produces bytecode for a release build, and the bundled Hermes engine loads and executes it on device. The engine owns JS objects and garbage collection; React Native supplies host APIs.
Imagine opening a policy list: the binary loads its JS bundle, modules initialize, React executes the first render and asks Fabric to represent host views. Bytecode avoids treating the source file as raw JS to parse at launch; it does not execute the screen for you.
Measure startup and heap in a release build on representative Android and iOS devices. Do not quote historical Hermes-versus-JSC gains as a guarantee for your app.
Large eager module initialization or a long synchronous JS task can dominate startup despite bytecode. Split and defer noncritical work, then compare the same user journey before and after.
Keep this: Hermes executes the app’s JS; the release bundle is normally compiled to Hermes bytecode at build time.
Check yourself: Does precompiled bytecode mean app startup and every JS operation become free?
No. It removes much runtime parse/compile work; loading, initialization, module evaluation, JS execution and GC still consume time.
Part 2 · Fundamentals
Fabric: three trees and three phases
Prerequisites: Start here / basic technical literacy
Representations and phases
Conceptual render path. Some render or commit work may use other scheduling scenarios. [S05][S06]
State update and sharing
Structural sharing is conceptual; the exact clone set depends on props and layout changes. [S05]
Mental model verified
Render reduces React elements to host components and creates an immutable C++ shadow tree. Commit calculates layout and promotes a next tree. Mount diffs against the rendered tree and applies host-view mutations on the UI thread.
A title changes from “Draft” to “Issued”. React updates affected elements; Fabric clones affected shadow nodes, shares unchanged nodes where possible, computes layout as needed and updates the host text view at mount.
Assuming “render completed” means “painted” leads to timing bugs. Use the appropriate layout and view APIs, then verify when the actual host view receives changes.
Keep this: React elements, C++ shadow nodes, and native host views are related representations with different owners.
Check yourself: Is every React composite component also a native UIView or Android View?
No. Host components produce shadow nodes; composite components do not necessarily correspond to host views.
Part 3 · Fundamentals
Two authoring paths over one runtime boundary
Prerequisites: Start here / basic technical literacy
Two authoring paths
The two paths share the app runtime; the drawing emphasizes authoring contracts, not a claim of identical generated glue. [S09][S11][S12]
Mental model verified
Both paths expose native functionality to the same JS app. TurboModules declare a TS/Flow spec that Codegen uses for platform glue. Expo Modules declare Name, Function, AsyncFunction, Events and View through a Swift/Kotlin module definition.
A document scanner might expose scanAsync(): Promise<Result> through either path. The native implementation and SDK interaction remain Android and iOS code; the JS facade normalizes the result shape.
Choose RN Codegen when C++ and generated interfaces are central. Choose Expo Modules when its Swift/Kotlin DSL and lifecycle helpers fit your team and dependencies. Measure actual bottlenecks rather than assuming one method call is decisively faster.
A matching method name does not guarantee the same permission, cancellation, or UI semantics. Define a cross-platform contract, then test its native branches independently.
Keep this: TurboModules use RN typed specs and Codegen; Expo Modules use a Swift/Kotlin DSL above JSI-related primitives.
Check yourself: Does an Expo Module create a separate JS runtime?
No. It exposes native functionality into the React Native app’s JS runtime.
Part 4 · Fundamentals
Kotlin and Swift meet the OS
Prerequisites: Start here / basic technical literacy
Platform resource ownership
The Promise is a JS interface; native lifecycle and OS callbacks decide when it settles. [S12][S16][S17]
Mental model verified
Android native code interacts with Activity, View, SDK callbacks and the main Looper. iOS code interacts with UIKit, application/scene lifecycle and the main actor or main queue. A module mediates these APIs for JS; the native platform still owns its resources and permissions.
A camera picker starts platform UI, returns a URI or asset handle, and is cancelled or completed by an OS callback. The module must translate that outcome into the JS contract and clean up if its owning screen disappears.
Make thread, ownership and lifecycle explicit for every native resource. Design cancellation, error mapping and permission denial before exposing a JS function.
Holding an old Activity or view reference after recreation can leak or crash. Tie work to a valid owner and test background/foreground, dismissal and recreation on devices.
JSI is a boundary API; the host object and underlying resource have explicit lifetimes. [S04][S12][S13]
Mental model verified
Hermes executes JS and owns the JS heap. JSI defines an engine-independent C++ interface through which React Native and native libraries can expose functions and host objects. A host object reference is a capability with lifetime and thread constraints, not free shared memory.
A native image decoder can keep an image buffer native and expose a small JS-visible handle, avoiding repeated huge JS payloads. The design still needs ownership rules and a copy/convert audit. This is an illustrative architecture.
Prefer an established module abstraction unless you need lower-level behavior. If using JSI directly, document which thread accesses the runtime, when native objects are released and whether values are copied.
Retaining a JS runtime value and touching it on a native worker can crash. Expo specifically restricts JavaScriptValue access to synchronous JS-thread functions. Keep worker payloads native or converted, then return through the module API.
Keep this: Hermes is an engine; JSI is an interface for native code to work with a JS runtime.
Check yourself: Does a JSI host object imply every value transfer is zero-copy?
No. It can hold native references, but a particular API may still allocate or convert payloads.
Part 6 · Applications
Fabric native view: props down, events up
Prerequisites: 02-fabric-trees
Props and events
The same component contract has separate Android and iOS host implementations. [S08][S10]
Mental model verified
A Fabric component spec describes props and events. Codegen creates interfaces. A platform view manager/component implements view creation and prop updates; events move from the host back toward JS. Nonvisual service calls belong in a module.
A scanner preview receives an enabled prop and emits onDetected. Android creates a View via a manager; iOS creates a UIView-backed component and updates props. Keep frame processing native and emit only meaningful detections.
Separate slow SDK initialization from UI-thread view mutation. Release camera or media resources when the view is destroyed, and avoid putting large frames in JS events.
An event fired after unmount can target stale listeners. Couple subscription, view lifetime and SDK shutdown; test mount/unmount loops on both platforms.
Keep this: A native visual surface needs a component contract, host view implementation and event path.
Check yourself: When should a device SDK be wrapped as a native view rather than only a module?
When it owns a visible platform surface such as camera preview, map, video player or specialized control.
Part 7 · Applications
TurboModule Codegen across Android and iOS
Prerequisites: 03-module-interface-map
Code generation paths
Generated glue is separate from platform business logic; its exact files depend on RN version and configuration. [S08][S09]
Mental model verified
React Native Codegen reads a typed spec from the configured source directory. Android output includes Java/Kotlin-facing abstract APIs and JNI/C++ glue; iOS output includes Objective-C++ and C++ interfaces. The native implementation satisfies those generated contracts.
For readSecureToken(): Promise<string | null>, define nullable result semantics in the spec, run the build/codegen, implement Android Keystore and iOS Keychain behavior, then normalize absent and denied outcomes. This signature is conceptual and not a complete spec.
Keep specs narrow, avoid passing unbounded collections, and separate platform service logic from generated glue. Regenerate whenever the spec changes and compile both platforms.
A JS type edit without regenerating and rebuilding native artifacts can leave contract drift. Use build checks on Android and iOS plus device integration tests.
A declarative Expo module maps the JS API to platform implementations. [S11][S12]
Sync versus async
AsyncFunction dispatches by default; UI work requires an explicit main-queue decision. [S12]
Mental model verified
Name exposes the JS module. Function is synchronous; AsyncFunction returns a Promise. View defines a native visual component, Events declares signals, and lifecycle hooks observe module/app/activity changes. Expo handles much of the type conversion.
A payment scanner wrapper might expose Function("isSupported") for a small cached Boolean and AsyncFunction("scanAsync") for SDK work. Both Kotlin and Swift implement the contract; the exact SDK calls and error types are platform-specific. This is pseudocode, not a complete module.
Use AsyncFunction for I/O or long work. Explicitly dispatch UI-related operations to the main queue; do not assume an arbitrary async native function is on main. Keep a TS facade that maps platform errors to stable results.
JavaScriptValue is tied to the JS runtime thread. Do not capture it into AsyncFunction worker work; convert it first or use a native shared object with a defined lifetime.
Keep this: A module definition publishes functions, views and events while choosing native execution queues.
Check yourself: What is the difference between Function and AsyncFunction?
Function runs synchronously on the JS caller thread; AsyncFunction returns a Promise and normally dispatches native work away from it.
Part 9 · Best practices
Hermes memory: JS heap is only part of the story
Prerequisites: 01-hermes-pipeline, 05-hermes-jsi
Two memory domains
Separate observations. The diagram does not imply a one-to-one allocation mapping. [S04][S13][S14]
Mental model synthesis
Hermes owns JS values and reclaims unreachable JS objects. A JSI host object can reference a separate native resource; a JS heap snapshot alone cannot account for every native allocation. React Native DevTools can inspect the JS heap, while platform tools are needed beneath it.
A gallery grows 20 MB after each open/close. Compare JS heap snapshots, Android Studio/Xcode native memory, and the number of retained image contexts. A stable JS heap plus growing native allocation points toward the native resource path, not proof of a GC defect.
Define resource release at unmount, module destruction and cancellation. Avoid retaining callbacks and long-lived handles without an owner. Measure steady-state after repeated navigation.
Calling GC manually is not a substitute for fixing a retained reference. Trace object ownership and eliminate the root; rerun the same loop to establish whether memory plateaus.
Keep this: Separate JS heap growth from native allocation and resource retention before blaming GC.
Check yourself: Would a flat JS heap rule out a native image leak?
No. Native buffers and platform views can grow outside the JS heap.
Part 10 · Best practices
Fabric scheduling: JS, layout and UI are different budgets
Prerequisites: 02-fabric-trees
Common scheduling path
A common path, not the only scheduling path. High-priority work can render on UI. [S05][S06]
Frame diagnosis
Use both JS and platform traces to localize a frame problem. [S06][S14]
Mental model verified
The common path renders on the JS thread, but a high-priority event can lead to synchronous render work on UI. Commit/layout may occur elsewhere, while mount applies host-view mutations on UI. Immutable shadow structures enable thread-safe scheduling.
A scroll janks even when JS appears mostly idle. Inspect UI-thread work, layout and mount costs, not just React component time. A huge custom native view mutation can still stall the frame.
Budget expensive native view changes; batch related state updates when appropriate and measure on actual devices. Avoid assuming a synchronous JSI call has no scheduling consequences.
A generic “bridge latency” diagnosis misses mount cost. Compare JS trace, renderer work and platform main-thread trace, then optimize the measured segment.
Keep this: The render pipeline can span threads, but host-view mutation remains a UI-thread operation.
Check yourself: Can Fabric ever do render work on the UI thread?
Yes. The renderer supports high-priority UI-thread scenarios; do not model all work as an invariant JS-only chain.
Part 11 · Best practices
Module call threading and payload shape
Prerequisites: 07-turbo-codegen, 08-expo-dsl
Thread hops for an async task
Illustrative queue plan. Actual Expo and TurboModule implementations control their own dispatch. [S09][S12][S15]
Mental model verified
Expo Function executes on the JS runtime thread. AsyncFunction normally dispatches native work away from JS and can use a specified queue. Platform UI APIs still require main. A TurboModule contract can expose sync or async methods, but its implementation determines blocking behavior.
For OCR, return a file URI or handle to native code, process off main, emit a small result and resolve the JS Promise. Transferring full frames through JS on every preview frame adds conversion and rendering pressure.
State max payload, cancellation and timeout semantics in the public API. Profile call overhead, conversion, native SDK time and callback fan-out separately.
Keep this: A Promise is an API result, not proof that no work blocks a critical thread.
Check yourself: What makes a synchronous function hazardous even with JSI?
It blocks its caller while native work runs; I/O, locks or expensive conversion can stall JS.
Part 12 · Best practices
Android Activity and iOS app lifecycle are not interchangeable
Prerequisites: 04-platform-ownership, 08-expo-dsl
Platform lifecycle forks
The same public result is backed by distinct native lifecycle signals. [S12][S16][S17]
Request state machine
A conceptual single-settlement contract; platform SDK callbacks and ownership determine implementation. [S12][S16]
Mental model verified
Expo’s hooks distinguish iOS app foreground/background from Android Activity foreground/background/destruction. Android coroutines can be tied to lifecycle scopes; UIKit work uses main-thread APIs, and Swift MainActor can express UI isolation.
A biometric prompt begins, then the app backgrounds. On Android, handle Activity lifecycle and a cancelled or deferred result; on iOS, handle app state and SDK callback. Both paths should settle or explicitly cancel the JS request once, with a stable result shape.
Model pending, completed and cancelled states; clean up native listeners and avoid retaining stale view or Activity references. Test denial, background, process death and app relaunch as relevant.
A pending Promise that never resolves after dismissal is a UX and resource bug. Track request IDs and terminal states; verify each path settles exactly once.
Keep this: Expose a common JS result but respect different native lifecycle and permission paths.
Check yourself: Can a module assume an Activity that launched a request still exists when it returns?
No. The Activity may pause, be destroyed or be recreated; the module must handle missing owners and cancellation.
Part 13 · Further improvements
Hermes diagnosis lab: startup, long tasks and heap
Prerequisites: 09-hermes-memory
Performance hypothesis loop
An experimental plan; it asserts no universal Hermes performance gain. [S01][S14]
Baseline verified
Record cold start, first useful screen, JS execution and memory in comparable release builds. React Native DevTools traces and heap snapshots explain JS behavior; Android Studio and Xcode explain native paths.
If JS module evaluation dominates startup, defer optional imports and initialization. If a retained JS object dominates memory, remove the reference. Compare identical devices and journeys; these are proposed tests, not measured gains.
A dev build and remote debugging path can distort runtime behavior. Verify Hermes is actually active and repeat the profile on the release configuration users receive.
Keep this: Treat engine tuning as a measured hypothesis in the app’s full startup and interaction path.
Check yourself: What would make a Hermes upgrade a poor explanation for a slow screen?
A trace showing the time is dominated by native SDK initialization, image decode, network or UI mount rather than JS engine work.
Part 14 · Further improvements
Fabric diagnosis lab: render, commit or mount?
Prerequisites: 10-fabric-scheduling
Find the costly phase
The path localizes phases; scheduling and overlap vary by scenario. [S05][S06][S14]
Baseline synthesis
Capture a slow interaction and note JS render work, layout/commit pressure and UI-thread host mutations. Fabric’s tree model explains why these costs need separate observation.
If repeated React updates create needless work, reduce them. If a custom host view blocks main, reduce work in its prop update or move non-UI processing off main. Compare dropped frames and latency on the same device.
Aggressive batching may delay feedback; native offloading introduces synchronization and resource ownership. Choose a change based on the interaction’s latency target.
Optimizing shadow-node count from a single screenshot can miss the scroll or animation path. Trace a representative sequence and repeat after the change.
A practical decision aid, not an exclusive taxonomy; mixed designs are common. [S09][S10][S11][S13]
Baseline synthesis
List each native capability as a synchronous query, asynchronous operation, stream or view. Identify payload size, required thread, resource owner and platform-specific failure paths.
Use Expo Modules for concise Swift/Kotlin modules and lifecycle/type conversion helpers; use TurboModules for RN Codegen or direct C++ needs; use a Fabric/Expo native View for visual surfaces. SharedObject can expose long-lived native instances where appropriate.
Every abstraction carries dependency and API-shape choices. Direct JSI maximizes control but makes thread, conversion and lifetime responsibilities more explicit. Benchmark only after a concrete bottleneck appears.
Build both platforms, exercise cancellation and lifecycle, measure repeated call/event flows, and ensure native resources release when no JS/native reference remains.
Keep this: Pick the smallest public API that expresses required behavior, then select a module mechanism.
Check yourself: When is a SharedObject useful?
When a long-lived native instance such as a decoder or database context should be referenced from JS without repeatedly recreating it.
Part 16 · Further improvements
Cross-platform contract and native release safety
Prerequisites: 15-module-design-choice
Build versus update surface
Native API changes require a new binary; the update must match its compatible runtime. [S19][S20]
Baseline verified
Record the public JS API, native dependencies, platform permissions, config plugins and runtimeVersion for each release. An OTA update replaces a compatible JS layer, not the compiled native layer.
CI can build both platforms, exercise JS contract tests against native implementations, and test an update against a preview binary with the same runtimeVersion before rollout. This is a proposed gate, not a claim about an existing pipeline.
A strict fingerprint-based runtime policy may require more binaries but reduces accidental compatibility mistakes; a manual policy offers control and demands discipline.
Changing AndroidManifest.xml or Info.plist manually in a CNG project can be overwritten at prebuild. Preserve native configuration in app config/plugins and verify generated outputs.