1 of 52

Working with Native Modules

Callstack

Training Program

2 of 52

Module Agenda

01

Foundations and the Bridge Era

02

JSI and the Two Paths: Turbo and Nitro

03

Building a Module Side by Side

04

Native Views Side by Side

05

Real-World Pitfalls in Production

06

When to Pick Which, and Wrap-Up

3 of 52

3

What are Native Modules?

The doors between JavaScript and platform APIs

JavaScript runs your app logic

But the device's real powers (camera, Bluetooth, sensors, file system, secure storage) live in native code

Two flavors of native modules

Native Modules (invisible logic, like permission checks or crypto) and Native Components (visible UI, like a camera preview)

Why they exist

JavaScript on its own cannot reach Bluetooth, the camera sensor, or the secure keychain. Native modules expose these to your JS code

Most apps consume them, do not write them

Libraries like react-native-mmkv, react-native-vision-camera, and react-native-quick-crypto are all native modules someone else wrote for us

You write one when there is no library yet

The platform API is not exposed by RN core or any maintained library, or you are wrapping a proprietary native SDK

Rule of thumb: search npm and React Native Directory before writing one yourself. Most needs already have a maintained library.

4 of 52

4

When You Actually Need One

Five honest reasons to write your own native module

No library exists for this platform API

The native API exists on iOS or Android, but no JavaScript library wraps it. Writing the binding yourself is the only way to use it.

Existing library is unmaintained or buggy

The library exists but has not been touched in two years, has open critical issues, or does not support the new architecture.

You are wrapping a proprietary native SDK

A vendor SDK only ships native binaries (iOS framework, Android AAR), so the JavaScript binding has to be written in-house if you want to use it.

Your hot path is too slow in JS

Image processing, audio buffers, ML inference, and frame processors all need to run faster than JavaScript can deliver at 60fps. Native code is the practical option for these.

You need OS-level integration

Background tasks, push notification handlers, share extensions, and widgets all run outside the JavaScript runtime, so they often need native code, especially on iOS for things like Notification Service Extensions and Notification Content Extensions.

If your reason is not on this list, a JavaScript-only solution is usually simpler to maintain and worth trying first.

5 of 52

5

A Short History: The Bridge Era (2015 to 2024)

How JavaScript talked to native code, and why it had to change

2015: React Native ships with the Bridge

JS and native ran on separate threads, talking through a "bridge" that serialized every message as JSON, batched it, and shipped it across asynchronously.

The Bridge mental model

Imagine two offices that can only communicate by mailing letters. Even simple questions have to be written down, posted, and waited on for a reply.

Three permanent ceilings

The bridge was asynchronous (no synchronous reads from native), every call paid a JSON serialization cost, and there was no type safety between the JavaScript and native sides.

Oct 2024: New Architecture becomes default

RN 0.76 ships with the New Architecture on by default. JSI, Fabric, and Turbo Modules replace the bridge.

April 2026: RN 0.85 finished the cleanup

Orphaned legacy classes are removed or deprecated. RN 0.82.x moves to unsupported. Interop layers for legacy modules remain in the codebase by design

Oct 2025: RN 0.82 makes the New Architecture mandatory

From this version on, every app runs on JSI. This is the practical end of the bridge era for application developers.

6 of 52

6

From Bridge to JSI

How JavaScript talks to native, before and after

Oct 2024

7 of 52

7

JSI: The Foundation Beneath Both

The thin C++ layer that replaced the bridge

JavaScript Interface (JSI)

A thin C++ API that lets JavaScript and native code reference each other's objects directly, removing the JSON serialization, message batching, and asynchronous waiting that the bridge required.

Synchronous calls become possible

JavaScript can ask native code for a value and receive it immediately, without wrapping every cross-boundary call in a Promise.

The new mental model

The mailing-letters analogy from the previous slide changes: the two offices now share a hallway, and native functions can be invoked as easily as functions in the same module.

Both Turbo and Nitro Modules sit on JSI

JSI is the shared foundation, and Turbo Modules and Nitro Modules are two different frameworks built on top of it. They share the JSI layer but differ in the abstractions and tooling above it.

Codegen is the connective tissue

Both frameworks generate native binding code from a TypeScript spec. RN core calls this "Codegen", Nitro calls it "Nitrogen".

Most application developers never write JSI code directly and instead use the frameworks built on top of it.

8 of 52

8

Two Paths on JSI

Same foundation, different developer experience and performance ceiling

Turbo Modules: Meta, ships with React Native

Turbo Modules ship as part of React Native core, so consumers do not install an extra dependency. Most migrated third-party libraries have chosen this path.

Nitro Modules: Margelo, third-party

Built by Marc Rousavy and the Margelo team. Adds a dependency, but offers measurably better performance and a much nicer authoring experience.

Both use codegen from a TypeScript spec

Both frameworks read a TypeScript interface describing the module's public surface and use a code generator to produce the C++ glue that connects JavaScript to native code.

You can mix both in one app

Apps can mix Turbo Modules from one library and Nitro Modules from another in the same codebase without conflict, and this is common in practice.

Different mental models above JSI

Turbo exposes flat singletons with methods, while Nitro exposes object-oriented Hybrid Objects that can be instantiated. The next few slides show both shapes in code.

The choice between Turbo and Nitro is rarely about whether one works (both do), but about which set of trade-offs fits your project.

9 of 52

9

How They Compare at a Glance

Nitro Modules and Turbo Modules, side by side

Factor

Nitro Modules

Turbo Modules

Maintainer

Margelo (third-party)

Meta (RN core)

How to install

react-native-nitro-modules

Already in React Native

iOS language

Pure Swift via Swift/C++ interop

Obj-C++ (Swift via bridge)

Android language

Pure Kotlin via fbjni

Java/Kotlin via JNI

Mental model

Hybrid Objects you instantiate

Flat singleton with methods

Maturity

0.x version, growing fast

Stable as of RN 0.76+, ecosystem default

Codegen runs

Library author, files committed

App build time

10 of 52

10

The Two Mental Models, in Code

The same Math module, written in both frameworks

Same problem, two different shapes. Turbo gives you a singleton with methods. Nitro gives you a class you can instantiate.

The next slide unpacks what each line is doing. We will then build the matching native code on iOS and Android.

NativeMath.ts (Turbo)

Math.nitro.ts (Nitro)

11 of 52

11

Unpacking the Code, Line by Line

The shape of each mental model, line by line

Turbo: a singleton you import

TurboModuleRegistry.getEnforcing returns one global instance that is imported and called like any other module, with no equivalent of new Math().

Nitro: a class you instantiate

HybridObject is a class that supports multiple instances, can be passed around like any object, and behaves much like a normal TypeScript class.

Platform language is in the type

HybridObject takes a generic { ios: 'swift', android: 'kotlin' }. Nitrogen reads this and generates Swift and Kotlin glue accordingly.

Properties are real divergence

Nitro turns readonly pi: number into math.pi directly. Turbo has no equivalent: declare constants as getConstants(): { pi: number }, accessed in JS as Math.getConstants().pi. Both are type-safe through codegen.

Both keep you type-safe

The TS spec is the contract. Codegen and Nitrogen both fail the build if your native code does not match the spec.

Choose the mental model that fits the problem. Turbo works when one global instance is sufficient, and Nitro works when multiple instances are needed.

12 of 52

Building a Module Side by Side

Callstack

Training Program

13 of 52

13

The Example We Will Build

A simple Math module, written twice for direct comparison

What it does

The module exposes one constant (pi: number) and one method (add(a, b): number). The example is intentionally trivial so the framework differences are easier to see.

Why this example

A camera or audio example would split attention between the framework and platform APIs. Math keeps the focus on the frameworks themselves.

What stays the same

The TypeScript spec describes the same shape in both frameworks, the JavaScript-side usage looks broadly similar, and the observable runtime behavior is equivalent.

What differs

Differences appear in file names, codegen tooling, native code shape, registration steps, and even how the module is instantiated. Each one reflects a design choice.

How to read these slides

Each step shows code on one slide and talking points on the next. Skimming the code first usually works best.

Code samples follow the patterns shown in the official Margelo and React Native documentation, with links in the resources slide at the end.

14 of 52

14

Step 1: Write the TypeScript Spec

Both frameworks start the same way: a TS file describing the module's public surface

File naming is the first difference: Turbo uses a Native prefix on spec filenames, and Nitro uses a .nitro.ts suffix.

The TypeScript spec is the single source of truth, and codegen reads it next to generate the native binding code.

Note the spec asymmetry: Nitro can declare readonly pi: number directly on the interface, while Turbo specs must declare getConstants(): { pi: number } instead.

NativeMath.ts (Turbo)

Math.nitro.ts (Nitro)

15 of 52

15

Reading the Spec

Five things to notice when looking at either spec

It is just a TypeScript interface

Both specs extend a base interface (TurboModule or HybridObject) that codegen recognizes, and otherwise read like any other TypeScript contract.

The base type encodes intent

Extending TurboModule signals to codegen that the module should be registered as a singleton. Extending HybridObject<{ios, android}> signals that nitrogen should build a native class.

Methods are typed end to end

Codegen turns the TypeScript argument and return types into Swift and Kotlin signatures. Mismatches fail at build time, not at runtime.

Constants vs methods

Nitro exposes pi as a readonly property accessed directly as math.pi. Turbo has no property syntax in codegen specs; constants must be declared via getConstants(): { pi: number } and accessed in JS as Math.getConstants().pi. Both shapes are type-safe through codegen, but only Nitro maps to a direct property access on the JS side

The spec is your single source of truth

Editing the TypeScript file and regenerating updates the native binding code. Bypassing the spec returns you to unsafe pre-bridge patterns.

Tip: keep specs in a /specs folder. Easier for codegen to find, and easier for new devs on the team to spot.

16 of 52

16

Step 2 (Turbo): Configure Codegen

One block in package.json. Codegen runs at build time.

You write the codegenConfig once. Codegen reads it on every iOS pod install and every Android Gradle build.

Generated files live under build/ on iOS and Android. They are gitignored and rebuilt every time.

What gets generated

my-app/

├── specs/

│ └── NativeMath.ts

├── ios/

│ └── build/generated/ios/

│ └── NativeMathSpec.h

└── android/

└── build/generated/source/

└── codegen/

├── java/com/nativemath/

│ └── NativeMathSpec.java

└── jni/ (CMakeLists.txt, .h, .cpp)

package.json

{

"codegenConfig": {

"name": "NativeMathSpec",

"type": "modules",

"jsSrcsDir": "specs",

"android": {

"javaPackageName":

"com.nativemath"

}

}

}

17 of 52

17

Reading the Codegen Setup (Turbo)

What that package.json block actually does

name: the artifact codegen produces

NativeMathSpec is what gets generated as a C++/Obj-C++/Java header and class. Other parts of the codebase will reference this name.

type: "modules" not "components"

We are building a module (logic), not a component (a UIView). Use "components" when you have a visible native view.

jsSrcsDir: where to look for specs

Codegen scans this folder for files starting with Native. Keeping all specs in one folder makes the search predictable.

android.javaPackageName: the package the code lives in

On Android the generated Java/Kotlin code lives in this package. Match it to your app's actual package to avoid linker confusion.

Codegen runs automatically

On iOS, codegen runs during pod install. On Android, it runs during the Gradle build. You usually do not invoke it manually.

Tip: if codegen produces nothing, double-check the spec filename starts with Native and the file is inside jsSrcsDir.

18 of 52

18

Step 2 (Nitro): Configure Nitrogen

You run nitrogen yourself when the spec changes.

Generated files are committed to the library repository rather than regenerated on every app build, which gives consumers faster install times.

After editing a .nitro.ts spec, running npx nitrogen scans the spec files in the project and generates the Swift, Kotlin, and C++ glue.

What gets generated

my-module/

├── src/specs/

│ └── Math.nitro.ts

├── nitrogen/generated/

│ ├── ios/

│ │ └── HybridMathSpec.swift

│ ├── android/

│ │ └── HybridMathSpec.kt

│ └── shared/

│ └── (C++ JSI glue)

└── nitro.json

nitro.json

{

"cxxNamespace": ["math"],

"ios": { "iosModuleName": "NitroMath" },

"android": {

"androidNamespace": ["math"],

"androidCxxLibName": "NitroMath"

},

"autolinking": {

"Math": {

"ios": {

"language": "swift",

"implementationClassName": "HybridMath"

},

"android": {

"language": "kotlin",

"implementationClassName": "HybridMath"

}

}

}

}

19 of 52

19

Reading the Nitrogen Setup

What that nitro.json block actually does

cxxNamespace: where the C++ glue lives

Nitro generates a C++ namespace for the JSI bindings, so a short namespace matching the library's identity works best.

ios.iosModuleName, androidCxxLibName

The names of the iOS module and Android C++ library that nitrogen produces. They become identifiers consumers might log, so pick something readable.

autolinking: connect TS spec names to native classes

For each TS HybridObject name, you tell nitrogen which native class implements it on each platform. This is how createHybridObject('Math') finds your code.

When to run nitrogen

Nitrogen runs manually after any edit to a *.nitro.ts file, and most teams script this in their package.json. Unlike Turbo's codegen, it is not invoked automatically during app builds.

Commit the generated files

Generated files live under nitrogen/generated/ in the library repository and are checked into git, so consumers never regenerate them.

Tip: add npx nitrogen to your library's prepublish script. Stale generated code is a silent bug source.

20 of 52

20

Codegen vs Nitrogen

Same job, different timing and different file destinations

21 of 52

21

Step 3: iOS Implementation

Pure Swift on the left, Obj-C++ on the right

Nitro allows the entire iOS module to be written in idiomatic Swift, while Turbo requires an Obj-C++ implementation file even when wrapping Swift code. Both implementations satisfy the same TypeScript spec, but they differ in the amount of work involved and the number of languages each touches.

RCTNativeMath.mm (Turbo)

HybridMath.swift (Nitro)

22 of 52

22

Reading the iOS Implementations

Five differences worth noticing

Nitro: a single Swift file with no bridging glue

HybridMath inherits from the nitrogen-generated HybridMathSpec, and the file you write contains only the implementation, with no bridging glue.

Turbo: Obj-C++ is the canonical path

RCT_EXPORT_MODULE registers the module and each method becomes an Obj-C selector. Swift is supported but requires an additional bridging header step.

Constants are exposed differently

Nitro uses a typed property accessor for constants, while Turbo uses a getConstants method returning an NSDictionary that is then mapped to JavaScript.

Memory and threading

Nitro Hybrid Objects are reference-counted by JavaScript through JSI's NativeState mechanism, while Turbo modules are singletons that live for the app's full lifetime.

Error reporting

Nitro methods can throw native errors that propagate to JavaScript as Promise rejections or thrown errors, while Turbo requires explicit calls to promise.reject inside callback blocks.

Writing Turbo modules in Swift means maintaining a bridging header and an Obj-C++ entry point, so the implementation lives in two languages.

23 of 52

23

Step 4: Android Implementation

Pure Kotlin on the left, Kotlin via Java/JNI patterns on the right

Both frameworks expose Kotlin to JavaScript, but Nitro generates the JNI layer for you, while Turbo expects familiarity with how its base class wraps JNI.

Both implementations compile and run even for developers who have never written JNI directly, because the annotations and base classes handle the bridge details.

NativeMathModule.kt (Turbo)

HybridMath.kt (Nitro)

24 of 52

24

Reading the Android Implementations

Where Kotlin meets the JSI bridge

Nitro: extends a generated Spec, no JNI in your code

Nitrogen produces HybridMathSpec, and the implementation overrides its abstract methods. The C++ glue and JNI bridge are auto-generated and never appear in your code.

Turbo: extends NativeMathSpec, takes a context

Codegen produces NativeMathSpec, and the implementation overrides its methods. The constructor takes a ReactApplicationContext, which Turbo uses for JNI hooks.

Property accessors vs getConstants

Nitro maps a TypeScript readonly property to a Kotlin val with a getter, while Turbo continues to use getConstants returning a Map, mirroring its iOS pattern.

Threading model

Both frameworks call your method on the JavaScript thread by default, so any long-running work should be dispatched to a coroutine or Executor to keep the UI responsive.

Registration

On Nitro, HybridMath is registered in HybridObjectRegistry during app startup. On Turbo, the module is registered via BaseReactPackage.getModule() plus getReactModuleInfoProvider(). The ReactModuleInfo flag isTurboModule = true distinguishes it from a legacy native module.

25 of 52

25

Step 5: Calling From JavaScript

How the JS-side usage differs

Turbo provides a global singleton imported once per app, while Nitro provides a class that can be instantiated, supporting multiple live instances at the same time.

For most modules the singleton pattern is sufficient, but when multiple instances are needed (for example, per user or per session), Nitro is the framework that supports this natively.

usage.tsx (Turbo)

usage.tsx (Nitro)

26 of 52

26

Singleton vs Instance: Why It Matters

When the JS-side shape changes the architecture

Singletons fit most modules

Stateless modules (crypto, math, file IO) and modules with a single global state (the current user, the current session) are well served by Turbo's singleton model.

Instances unlock new patterns

Multiple Camera instances, per-document encryption keys, per-tab database connections. These are awkward to model with singletons but natural with Nitro.

Memory implications

Turbo singletons load lazily on first access, then live for the rest of the app lifetime. Nitro instances are reference-counted through JSI's NativeState and freed when JavaScript drops the last reference .

Constructor arguments

Nitro accepts arguments to createHybridObject, which is useful for instance-scoped configuration, while Turbo singletons take no constructor arguments by definition.

Performance characteristics

Per-call overhead is similar in both frameworks. The instance pattern adds a small one-time construction cost, usually negligible.

Tip: if a singleton is mostly what you need but Nitro's other benefits matter, storing one Hybrid Object in a module-level const works well.

27 of 52

27

Step 6: Async Methods and Promises

Both frameworks return promises to JS, but the native authoring experience differs

Nitro allows native methods to use idiomatic async patterns (Swift async/await, Kotlin suspend), while Turbo wraps everything in a Promise type that must be resolved manually on the JavaScript thread. Reach for async only when the work genuinely cannot complete synchronously, since sync calls cost less and produce simpler stack traces.

fetchScore.mm (Turbo)

fetchScore.swift (Nitro)

28 of 52

28

Reading the Async Implementations

What changes when methods are not synchronous

Sync first, async only when needed

JSI allows methods to return synchronously when the work is fast. Reserve async for slow operations: network, disk I/O, GPU work, large computations.

Nitro: Promise<T> with an async closure

The native signature is throws -> Promise<T> on Swift and : Promise<T> on Kotlin. The body wraps async work in Promise.async { ... }, where ordinary async/await or coroutine code runs.

Turbo: explicit Promise blocks

Turbo passes resolver and rejecter blocks into the method, to be called when the work finishes. Forget to call either and the Promise hangs indefinitely.

Threading caveat

Both frameworks resolve the JavaScript Promise on the JS thread. If the work runs on a background thread, the framework hops back automatically.

Cancellation

Neither framework provides a built-in cancellation token. If JS unmounts mid-flight, the native work keeps running. Pass an explicit cancel signal if cancellation matters.

Tip: prefer sync methods for anything that completes in under one frame (about 16ms), since the promise overhead is real even when it looks small.

29 of 52

29

Step 7: Events and Callbacks

How native code pushes data back to JS

Turbo declares typed EventEmitter<T> fields on the spec, and codegen produces a typed emitter natively plus a typed subscription on JS.

Nitro skips the event abstraction and accepts JavaScript functions as first-class arguments to native methods

events.mm (Turbo)

callbacks.swift (Nitro)

30 of 52

30

Reading the Event and Callback Implementations

Why this is the deepest design difference

Turbo: typed events via codegen

Declare readonly onProgress: EventEmitter<number> on the spec. Codegen generates a typed emitOnProgress() method natively and a typed subscription on JS. Mismatches fail at build time, not silently at runtime.

Nitro: callbacks as first-class arguments

JavaScript passes a function as an argument and native code invokes it directly. The call gets type safety, refactor safety, and IDE autocomplete on the callback signature.

Callback return values

Nitro callbacks can return values back to native code, while Turbo's event pattern is fire-and-forget and cannot synchronously read a result from JavaScript.

When events fit better

When multiple JS components need the same broadcast (push notifications, app state changes), the event-emitter pattern is the right tool regardless of framework.

Tip on Nitro: HybridView event props need to be wrapped with the callback() helper, since React Native's renderer does not yet allow bare function refs to propagate prop updates correctly.

31 of 52

31

Type Support Cheat Sheet

What TS types each framework supports across the boundary

TypeScript Type

Nitro Modules

Turbo Modules

number, boolean, string

yes

yes

Promise<T>

yes (native async/await)

yes (resolver/rejecter)

Callback (T) => void

yes (first-class function)

yes (Callback type)

Callback with return value

yes

no (events only)

ArrayBuffer

yes (zero-copy)

no (RFC pending)

Tuples

yes

no

Unions

yes

no (callbacks only)

Record<string, T>

yes

no (use object schema)

32 of 52

32

HybridObject Lifecycle

From TS spec to JS instance, step by step

33 of 52

Native Views, Side by Side

Callstack

Training Program

34 of 52

34

Native Views

The visible counterpart of native modules

Same dual path: Fabric vs HybridView

Turbo Modules pair with Fabric Components for native UI, and Nitro Modules pair with HybridView, mirroring the same architectural split as on the module side.

Same codegen pipeline as modules

The TypeScript spec describes the view's props and events. Codegen or nitrogen produces the binding code. The implementation is the only piece written by hand.

Different requirements than modules

Views handle layout, recycling in lists, prop updates between frames, and platform threading (UIView on main, View on UI thread).

Most apps consume views, do not write them

As with modules, most needs are already met by libraries like react-native-vision-camera, react-native-svg, and react-native-maps, so authoring custom views is rare.

RN 0.78+ required for HybridView

Nitro's HybridView requires React Native 0.78 or newer per the official Nitro docs, so older apps need to use Fabric components instead.

When the view is a thin wrapper around an existing native control, either framework works equally well.

The platform-specific implementation is most of the work regardless.

35 of 52

35

iOS View: Fabric vs HybridView

Same shape, different base classes and event-handling

Both implementations wrap a UIKit view, with Nitro allowing the entire view to be written in Swift, while Fabric on iOS uses the RCTViewComponentView base class (Obj-C++). Event-prop wrapping is one of the trickier Nitro details, and the next slide covers that along with other view-specific gotchas.

RCTMapView.mm (Fabric)

HybridMapView.swift (Nitro)

36 of 52

36

Reading the iOS View Code

Five differences worth noticing

Different base classes

Nitro views extend a generated Spec class (here HybridMapViewSpec) and Fabric views extend RCTViewComponentView. Both base classes provide a UIView lifecycle along with prop-update hooks.

Prop updates: typed vs untyped

On Nitro, each prop is its own typed setter generated from the TypeScript spec (var region: Region with a didSet observer). On Fabric, updateProps:oldProps: provides C++ Props structs that the implementation casts to its concrete type.

Event props need callback() wrapping

On the JSX side, wrap event-prop function refs with the callback() helper from react-native-nitro-modules. Bare function refs do not propagate updates correctly through React Native's renderer. The Swift side stores them as regular function properties and calls them directly.

Threading

All UIKit work must run on the main thread. Heavy work goes on a background queue, then dispatch_async back to main for the UI changes.

Recyclable views

Inside FlatList or ScrollView, implement RecyclableView's prepareForRecycle() to reset state. Otherwise stale data appears on reused cells.

Tip: SwiftUI views wrap inside HybridView via UIHostingController. Modern SwiftUI features become available without extra Turbo bridging.

37 of 52

37

Android View: Fabric vs HybridView

Same shape, different ViewManager versus HybridView base class

Both implementations wrap an Android View, with Nitro keeping the entire implementation in Kotlin, while Fabric requires familiarity with the ViewManager pattern and its codegen extensions. The ViewManager interface predates the new architecture, while HybridView starts from a cleaner slate built specifically for the new arch.

MapViewManager.kt (Fabric)

HybridMapView.kt (Nitro)

38 of 52

38

Reading the Android View Code

Where the patterns diverge most

Nitro: one class, no ViewManager

HybridMapView extends HybridView and its constructor receives a Context. Any Android View can be wrapped or composed inside; no ViewManager needed.

Fabric: ViewManager + Codegen interface

Fabric requires a SimpleViewManager subclass plus a codegen-generated interface implementation. Two pieces to maintain instead of one.

Prop setters: methods vs reactive

On Fabric, each prop is a separate @ReactProp method. On Nitro, one update path receives all props as a typed object, destructured as needed.

Lifecycle events

Nitro: prepareForRecycle() from RecyclableView for view recycling, and beforeUpdate/afterUpdate for prop batching. Fabric: onDropViewInstance for teardown.

Autolinking

Nitro views autolink through the same nitro.json autolinking block; JS imports go through getHostComponent. Fabric views need manual registration in ReactPackage.

Tip: Jetpack Compose works inside HybridView via ComposeView. State-driven Compose UI becomes available without leaving React Native's component model.

39 of 52

39

View-Specific Gotchas

Common pitfalls when authoring native views

callback() wrapping is required for event props

Bare function refs in HybridView event props do not propagate prop updates correctly through React Native's renderer, so callbacks need to be wrapped with the callback() helper from Nitro.

Implement RecyclableView for list cells

Inside FlatList or RecyclerView, implementing prepareForRecycle() to clear stale state is required. Skipping it leaks state across recycled cells.

Heavy work belongs off the UI thread

Both frameworks invoke the update method on the main/UI thread. Heavy work (decoding images, parsing data, computing geometry) belongs on a background queue. Dispatch back to the UI thread for the draw.

Autolinking via getHostComponent

On the JS side, import through getHostComponent('YourViewName') rather than the bare class. This gives the codegen-aware component with proper prop typing.

RN 0.78+ required for HybridView

HybridView requires React Native 0.78 or newer, so projects whose minimum supported React Native version is 0.77 or older must use Fabric for any new view code.

Tip: test the view inside a real list rather than in isolation, since recycling bugs typically only surface when many cells share a small pool of view instances.

40 of 52

Real-World Pitfalls

Callstack

Training Program

41 of 52

41

Nitro Production Pitfalls

Real issues from the field, with GitHub references

Windows + PNPM + Expo CMake failures

On Windows with PNPM and Expo, Nitro's native build can fail to link the C++ standard library. See nitro #747. Workaround: switch to npm or yarn, or use WSL on Windows.

Stale bindings after upgrade

Upgrading nitro versions can leave stale generated files that load before your fresh ones. See nitro #812. Fix: rm -rf node_modules ios/Pods android/build, then pnpm install and pod install.

Yarn 4 PnP, fixed in 0.32.0+

Yarn 4 had installation issues with Nitro 0.31.x (issue #1082, postinstall failure with spawn ENOTDIR). Resolved in v0.32.0-beta.0. If pinned to 0.31.x, switch to npm or pnpm, or downgrade Yarn.

Android segfault in older 0.31 builds, fixed in a later patch

A libnitromodules.so segfault on some Android devices (issue #1107) was traced to an incorrect ReactNativeVersion.h import path leaving ENABLE_NATIVE_OBJECT_CREATE unset. Upgrade if affected.

Generated files must be in the published tarball

If a Nitro library is published without running nitrogen first, the generated specs are missing from the tarball and consumer apps fail to build. Add npx nitrogen to your prepublish step.

Nitro is still at 0.x; issues like these are part of that stage. Pin a version, watch the changelog, budget time for upgrades.

42 of 52

42

Turbo Module Pitfalls

What goes wrong with codegen and the Turbo runtime

Codegen failures are silent

When codegen cannot find your spec, or hits a type it does not understand, it produces no output and no error. The native build then fails downstream with a confusing 'class not found' message.

Spec filename strictness

Codegen looks for filenames starting with Native (modules) or matching *NativeComponent.* (components). Misnaming produces no spec class and no error, so stick to the convention.

JS thread blocking on slow methods

Turbo's sync calls run on the JS thread by default. A 50ms operation drops two frames at 60fps. Use promise-based methods for anything that touches disk or the network.

Limited type support

Turbo does not support tuples, discriminated unions, or Records with arbitrary keys. The workaround is to flatten into objects with known keys, or use an ArrayBuffer with a custom framing protocol.

Obj-C++ for iOS Swift modules

A Turbo module in Swift requires a {ModuleName}-Swift.h bridging header, an RCT_EXPORT_MODULE call in an Obj-C++ file, and @objc annotations on the Swift side. Three languages for one module.

Turbo is stable, but the DX reflects its older lineage. Most pain comes from gradle and pod-install rituals, not runtime bugs.

43 of 52

43

Operational Truths

Things both frameworks share that are not in the docs

0.x version state for Nitro

Nitro is on 0.35.x as of this deck. Breaking changes can land on minor version bumps. Pin exact versions in package.json, not caret ranges.

Mixing Turbo and Nitro is supported

Both frameworks sit on JSI. An app can use Turbo from RN core, Nitro from third-party libraries, and its own custom Nitro modules together without conflict.

Module Federation interop is unsolved

When native modules load from a remote bundle via Module Federation, neither framework guarantees they will resolve. Native code has to be present at app build time.

Monorepo: where do generated files go

Nitrogen-generated files are checked in per package. Codegen-generated files are produced per app. In monorepos with many internal apps, this difference affects build-cache hit rates.

Iteration speed

Codegen runs on every install; nitrogen runs only when invoked. Manual is faster for local iteration; automatic is friendlier for consumed libraries.

Tip: keep a CHANGELOG.md for your own native modules. Versioning fragility is real, and a clear changelog pays off when revisiting code months later.

44 of 52

When to Pick Which?

Callstack

Training Program

45 of 52

45

When Turbo Modules Win

The honest case for staying with Turbo

You are publishing a public OSS library

Turbo ships as part of React Native core. Consumers of a Turbo library have no install steps beyond pod install. Nitro requires consumers to add react-native-nitro-modules as a dependency.

Stability matters more than DX

Turbo ships with each React Native release and has been the default since RN 0.76; Nitro is at 0.x and pre-stable. For native modules shipped to enterprise clients on long support cycles, Turbo is the safer pick.

Your team already knows the pattern

When much of the codebase is already Turbo with a working build pipeline, migrating to Nitro typically costs more than the developer-experience gain.

You are integrating with RN core APIs

Anything Meta ships (AppState, Linking, AccessibilityInfo, Alert) is a Turbo module. Extending or wrapping these naturally stays in the Turbo world.

Bundler ecosystem maturity

Metro alternatives like Re.Pack and Rspack have shipped more documented integrations with Turbo's spec files. Nitro works with these bundlers in principle but has fewer published worked examples. This is a JS-tooling concern, not a native-side difference.

Tip: when in doubt, default to Turbo. A single hot-path module can always be migrated to Nitro later, since mixing is supported.

46 of 52

46

When Nitro Modules Win

The honest case for choosing Nitro

Performance hot path

For modules called thousands of times per second (audio, video frames, sensor streams), Nitro's per-call overhead is consistently lower. The advantage grows when both sides are native code.

You want pure Swift or pure Kotlin

Nitro allows the entire module in idiomatic Swift or Kotlin without an Obj-C++ bridge. For teams that prefer the modern platform languages, that is a meaningful improvement.

Multiple instances are natural

Multiple Camera instances, per-document encryption keys, per-tab database connections. Awkward with singleton Turbo modules; natural with Nitro's Hybrid Objects.

Type-safe events and callbacks

Nitro accepts first-class function arguments instead of string-keyed events. The result is refactor-safe and is one of the strongest practical arguments for Nitro.

Richer TypeScript surface

Tuples, discriminated unions, Records, and ArrayBuffer (zero-copy) all work across the JSI boundary on Nitro. Complex specs avoid the flattening Turbo otherwise requires.

Choosing Nitro today means upgrading through 0.x breaking changes at some point. Plan that work into the roadmap rather than treating it as a surprise.

47 of 52

47

Decision Flowchart

Walking through the choice in three honest questions

48 of 52

48

What We Covered

A 5-bullet recap of the deck

The bridge is gone

JSI has replaced the bridge as the baseline for native communication. Synchronous calls, zero serialization, and direct C++ references are the standard, and both Turbo and Nitro build on it.

Two frameworks, both production-ready

Turbo Modules ship as part of React Native and are the ecosystem default, while Nitro Modules are a third-party alternative that offers pure Swift and Kotlin authoring along with a richer type system.

The choice is rarely all-or-nothing

Most apps consume both: Turbo from RN core and many OSS libraries, Nitro from third-party libraries that adopted it. Mixing the two in one app is fully supported.

Authoring is the rare case

Most application teams never write a native module. Most that do write only one or two. The framework choice matters most when writing, rarely defining the shape of an entire project.

When you do write one, default to Turbo

For OSS libraries, default to Turbo (zero install for consumers). For app-internal modules, the calculus shifts: pure Swift/Kotlin authoring, type-safe events, and richer types often justify Nitro for new code. Migrating between them later remains an option.

Whatever framework is chosen, document the decision. Future maintainers will need to know why a given module is Turbo and another is Nitro.

49 of 52

49

On the 15x Benchmark

What the headline number actually measures

The number

Margelo's NitroBenchmarks repo reports Nitro running addNumbers about 15x as fast as Turbo on an iPhone 15 Pro release build, across 100,000 calls.

Language asymmetry

Obj-C Turbo vs Swift Nitro on addNumbers. Drops to 5x for strings on the same device, ~4.6x on Mac Studio. The benchmark measures JS-to-native call overhead, not real work, so production speedup is smaller. Part of the gap reflects Nitro's better C++ codegen; part reflects Objective-C message dispatch overhead.

Apples to apples is closer

Implementing Turbo in Swift through an Objective-C bridge (closer to what Nitro generates directly) shrinks the gap substantially. Nitro stays faster, but by a smaller margin.

Real-world impact varies

For modules called once per user action, the per-call overhead difference is effectively invisible, while for 60fps frame processors and audio buffers the difference is real and measurable.

Pick on more than benchmarks

For most modules, factors like type safety, language preference, ecosystem alignment, and multi-instance support matter at least as much as per-call latency.

The 15x figure is real and repeatable, but it overstates the practical gap for most workloads. Cite it when scope is clear; benchmark your actual workload when accuracy matters.

50 of 52

50

Resources and References

Where to go next

Official documentation

Official sources: reactnative.dev/docs/turbo-native-modules and nitro.margelo.com. The Nitro docs include an unusually candid comparison page.

Callstack tutorials

The Callstack blog has migration guides for Turbo and a deep dive on Nitro by Burak Guner. Both walk through real-world examples beyond the Math.add use case.

Open source examples

Production examples to read: react-native-vision-camera, react-native-mmkv, react-native-quick-crypto (all Nitro). The awesome-nitro-modules list collects a growing roster of community libraries.

Benchmarks and performance

Margelo's NitroBenchmarks repo documents the methodology. Read the source before quoting numbers, and run the benchmarks against your own modules for a realistic picture.

Community channels

The Nitro Discord is responsive for community questions. RN core uses the React Native discussions repo on GitHub. Internal questions go to the Callstack #native-modules Slack channel.

The most useful understanding comes from picking a framework, shipping something with it, and dealing with the production friction firsthand.

51 of 52

51

Try it yourself

Every example in this deck is a real, end-to-end build you can run

Contents

  1. Base branch that has all four exercises integrated. Use this as a reference or run it locally to see how the final app runs.
  2. Four independent exercise branches that you can work on in any order you like

Each branch has a step-by-step README. Solutions are hidden behind collapsible sections, so you can attempt each task first

Repository

How to start

  1. Clone the repo locally
  2. Start by checking out the base code scaffold (git checkout scaffold)
  3. Work through the four exercises by following the steps in the README file of your target exercise branch
  4. See the integrated reference app (git checkout 00-final-app)

Happy coding!

52 of 52

Thank you and best studies!

Callstack

Training Program