Comparison with other frameworks
Nitro is not the only one of it's kind. There's multiple ways to build native modules for React Native:
- Nitro Modules
- Turbo Modules
- Legacy Native Modules
- Expo Modules
Benchmarks
This benchmark compares the total execution time when calling a single native method 100.000 times:
ExpoModules | TurboModules | NitroModules | |
---|---|---|---|
100.000x addNumbers(...) | 434.85ms | 115.86ms | 7.27ms |
100.000x addStrings(...) | 429.53ms | 179.02ms | 29.94ms |
Note: These benchmarks only compare native method throughput in extreme cases, and do not necessarily reflect real world use-cases. In a real-world app, results may vary. See NitroBenchmarks for full context.
It's not all about performance though - there are some key differences between Nitro-, Turbo- and Expo-Modules:
Turbo Modules
Turbo Modules are React Native's default framework for building native modules. They use a code-generator called "codegen" to convert Flow (or TypeScript) specs to native interfaces, similar to Nitro's nitrogen.
class HybridMath : HybridMathSpec {
func add(a: Double, b: Double) -> Double {
return a + b
}
}
@implementation RTNMath
RCT_EXPORT_MODULE()
- (NSNumber*)add:(NSNumber*)a b:(NSNumber*)b {
double added = a.doubleValue + b.doubleValue;
return [NSNumber numberWithDouble:added];
}
@end
Turbo Modules can be built with Objective-C for iOS and Java for Android, or C++ for cross-platform.
Shipped with react-native core
Unlike Nitro, Turbo Modules are actually part of react-native core. This means, users don't have to install a single dependency to build- or use a Turbo Module.
Implementation details
No Swift
There is no direct Swift support for Turbo Modules. You could bridge from Objective-C to Swift, but that would still always go through Objective-C, which is comparatively slower than bridging directly from C++ to Swift, like Nitro does.
No properties
A Turbo Module does not provide a syntax for properties. Instead, conventional getter/setter methods have to be used.
class HybridMath : HybridMathSpec {
var someValue: Double
}
@implementation RTNMath {
NSNumber* _someValue;
}
RCT_EXPORT_MODULE()
- (NSNumber*)getSomeValue {
return _someValue;
}
- (void)setSomeValue:(NSNumber*)someValue {
_someValue = someValue;
}
@end
Not object-oriented
While a Turbo Module can represent many types from JavaScript, there is no equivalent to Nitro's Hybrid Object in Turbo Modules. Instead, every Turbo Module is a singleton, and every native method is similar to a static method.
Native objects, like Image instances, can not be represented in Turbo Modules. Common workarounds include writing the image to a file, converting images to base64 strings, or using Blobs - which all introduce runtime overhead and performance hits just to pass an image instance to JS.
class HybridImageEditor: HybridImageEditorSpec {
func crop(image: HybridImage,
size: Size) -> HybridImage {
let original = image.cgImage
let cropped = original.cropping(to: size)
return HybridImage(cgImage: cropped)
}
}
@implementation ImageEditor
- (NSString*)crop:(NSString*)imageUri
size:(CGRect)size {
UIImage* image = [UIImage imageWithContentsOfFile:imageUri];
CGImageRef cropped = CGImageCreateWithImageInRect([image CGImage], size);
UIImage* croppedImage = [UIImage imageWithCGImage:cropped];
CGImageRelease(cropped);
NSString* fileName = [NSString stringWithFormat:@"%@.png", [[NSUUID UUID] UUIDString]];
NSString* filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName];
NSData* pngData = UIImagePNGRepresentation(croppedImage);
[pngData writeToFile:tempPath atomically:YES];
return tempPath;
}
@end
Using native objects (like the HybridImage
) directly is much more efficient and performant, as well as more convenient to use than to write everything to a file.
No tuples
There are no tuples in Turbo Modules.
type SomeTuple = [number, number]
No callbacks with return values
Turbo Modules do not allow JS callbacks to return a value.
type SomeCallback = () => number
Events
Since functions are not first-class citizens in Turbo Modules, you cannot hold onto a JavaScript callback in native code and call it more often, like you could in Nitro. Instead, Turbo Modules has "Events". Events are essentially just native functions that notify JS and potentially also pass data to JS more often.
class Math: MathSpec {
var listeners: [(String) -> Void] = []
func addListener(listener: (String) -> Void) {
listeners.add(listener)
}
func onSomethingChanged() {
for listener in listeners {
listener("something changed!")
}
}
}
@implementation RTNMath
RCT_EXPORT_MODULE();
- (NSArray<NSString *> *)supportedEvents {
return @[@"onSomethingChanged"];
}
- (void)onSomethingChanged {
NSString* message = @"something changed!";
[self sendEventWithName:@"onSomethingChanged"
body:@{@"msg": message}];
}
@end
Events are untyped and have to be natively defined via supportedEvents
. In Nitro, this would be fully typesafe as functions are first class citizens. (see addListener(..)
)
HostObject vs NativeState
As of today, Turbo Modules are implemented using jsi::HostObject
, whereas Nitro Modules are built with jsi::NativeState
.
NativeState has been proven to be much more efficient and performant, as property- and method-access is much faster - it can be properly cached by the JS Runtime and does not involve any virtual/Proxy-like accessors.
Additionally, Nitro Modules properly set up memory pressure per object, so the JS garbage collector actually knows a native module's memory size and can properly delete them when no longer needed. This is not the case with Turbo Modules.
Codegen
Codegen is similar to Nitrogen as it also generates native interfaces from TypeScript specifications. This ensures type-safety on the JavaScript side, as specs have to be implemented on the native side in order for the app to build successfully. This prevents any wrong type errors and ensures undefined/null-safety.
export interface Math extends HybridObject {
add(a: number, b: number): Promise<number>
}
export const Math =
NitroModules.createHybridObject<Math>("Math")
export interface Spec extends TurboModule {
add(a: number, b: number): Promise<number>;
}
export const Math =
TurboModuleRegistry.get<Spec>("RTNMath")
as Spec | null;
Codegen runs on app build
Nitrogen is executed explicitly by the library developer and all generated interfaces are part of the npm package to always ship a working solution. Codegen on the other hand runs on app build, which causes specs to always be re-generated for every app.
Codegen cannot resolve imports
While Nitrogen can properly resolve imports from other files, Codegen can not.
Codegen supports Flow
Codegen also supports Flow, while Nitrogen doesn't.
Legacy Native Modules
Prior to Turbo Modules, React Native provided a default approach for building native modules which was just called "Native Modules". Instead of using JSI, Native Modules were built on top of a communication layer that sent events and commands using JSON messages, both asynchronous and batched.
Because Turbo Modules are just an evolution of Native Modules, their API is almost identical:
class HybridMath : HybridMathSpec {
func add(a: Double, b: Double) -> Double {
return a + b
}
}
@implementation RTNMath
RCT_EXPORT_MODULE()
- (NSNumber*)add:(NSNumber*)a b:(NSNumber*)b {
double added = a.doubleValue + b.doubleValue;
return [NSNumber numberWithDouble:added];
}
@end
They are now deprecated in favor of Turbo Modules.
Expo Modules
Expo Modules is an easy to use API to build native modules by Expo.
Unlike both Nitro- and Turbo-, Expo-Modules does not have a code-generator. All native modules are considered untyped, and TypeScript definitions can be written afterwards.
An Expo Module can be written using a declarative syntax (DSL) where each function and property is declared inside the definition()
function:
class HybridMath : HybridMathSpec {
func add(a: Double, b: Double) -> Double {
return a + b
}
}
public class MathModule: Module {
public func definition() -> ModuleDefinition {
Name("Math")
Function("add") { (a: Double,
b: Double) -> Double in
return a + b
}
}
}
Implementation details
Swift support
Just like Nitro, Expo Modules are written in Swift, instead of Objective-C.
Expo Modules however bridge through Objective-C, whereas Nitro bridges to Swift directly (using the new Swift <> C++ interop) which has proven to be much more efficient.
Kotlin coroutines
Asynchronous functions can be implemented using Kotlin coroutines, which is a convenient pattern for asynchronous code. This is similar to Nitro's Promise.async
function.
class HybridMath: HybridMathSpec {
override fun doSomeWork(): Promise<String> {
return Promise.async {
delay(5000)
return@async "done!"
}
}
}
class MathModule: Module {
override fun definition() = ModuleDefinition {
Name("Math")
AsyncFunction("doSomeWork") Coroutine {
delay(5000)
return@Coroutine "done!"
}
}
}
Properties
Expo Modules supports getting and setting properties, just like Nitro.
Events
Similar to Turbo Modules, Expo Modules also uses Events to notify JS about any changes on the native side.
class Math: MathSpec {
var listeners: [(String) -> Void] = []
func addListener(listener: (String) -> Void) {
listeners.add(listener)
}
func onSomethingChanged() {
for listener in listeners {
listener("something changed!")
}
}
}
let SOMETHING_CHANGED = "onSomethingChanged"
public class MathModule: Module {
public func definition() -> ModuleDefinition {
Name("Math")
Events(SOMETHING_CHANGED)
}
private func onSomethingChanged() {
sendEvent(SOMETHING_CHANGED, [
"message": "something changed!"
])
}
}
HostObject vs NativeState
As of today, Expo Modules are implemented using jsi::HostObject
, whereas Nitro Modules are built with jsi::NativeState
.
NativeState has been proven to be much more efficient and performant, as property- and method-access is much faster - it can be properly cached by the JS Runtime and does not involve any virtual/Proxy-like accessors.
Expo-Modules do however properly set memory pressure of native objects, just like in Nitro.
Shared Objects
Expo Modules has a concept of "shared objects", which is similar to Hybrid Objects in Nitro.
I could not find any documentation for Shared Objects, so I cannot really compare them here.
No tuples
There are no tuples in Expo Modules.
type SomeTuple = [number, number]
No callbacks with return values
Expo-Modules does not allow JS callbacks to return a value.
type SomeCallback = () => number
No code-generator
Since Expo Modules does not provide a code-generator, all native modules are untyped by default. While TypeScript definitions can be written afterwards, it is possible that the handwritten TypeScript definitions are out of sync with the actual native types due to a user-error, especially when it comes to null-safety.
interface Math {
add(a: number, b: number | undefined): number
// b can be undefined here: ^
}
const math = ...
math.add(5, undefined)
// ^ will throw at runtime!
public class MathModule: Module {
public func definition() -> ModuleDefinition {
Name("Math")
Function("add") { (a: Double,
b: Double) -> Double in
// b CANNOT be undefined here: ^
return a + b
}
}
}
Note: It is also possible for Nitro specs to go out of sync, but only if you forget to run Nitrogen. In both cases, it's a user-error - one more likely than the other.
Supported Types
JS Type | Expo Modules | Turbo Modules | Nitro Modules |
---|---|---|---|
number | ✅ | ✅ | ✅ |
boolean | ✅ | ✅ | ✅ |
string | ✅ | ✅ | ✅ |
bigint | ✅ | ❌ | ✅ |
object | ✅ | ✅ | ✅ |
T? | ✅ | ✅ | ✅ |
T[] | ✅ | ✅ | ✅ |
Promise<T> | ✅ | ✅ | ✅ |
(T...) => void | ✅ | ✅ | ✅ |
(T...) => R | ❌ | ❌ | ✅ |
[A, B, C, ...] | ❌ | ❌ | ✅ |
A | B | C | ... | ✅ | ❌ | ✅ |
Record<string, T> | ❌ (no codegen) | ❌ | ✅ |
ArrayBuffer | ✅ | ❌ | ✅ |
..any HybridObject | ✅ | ❌ | ✅ |
..any interface | ❌ (no codegen) | ✅ | ✅ |
..any enum | ❌ (no codegen) | ✅ | ✅ |
..any union | ❌ (no codegen) | ❌ | ✅ |
Correctness of this page
Note: If anything is missing, wrong, or outdated, please let me know so I can correct it immediately!