The React Native New Architecture, powered by Fabric and TurboModules, brings major improvements in performance and developer experience. For most cases, TurboModules are the go-to solution: type-safe, auto-generated bindings that eliminate boilerplate and bridge overhead. But sometimes you need more. Sometimes the abstractions don’t cover your exact use case.
That’s where the JavaScript Interface (JSI) comes in!
-min.jpg)
JSI is the low-level API that underpins TurboModules. It lets you inject custom functions directly into the JavaScript runtime, hold references to JS objects, and move data without serialization. It’s the tool you reach for when TurboModules alone aren’t enough.
Why Go Beyond TurboModules?
Here’s a real example. While working on audio transcription, we needed to pass a large chunk of binary data (an ArrayBuffer
) from JavaScript into a native speech-to-text module. TurboModules didn’t yet provide direct support for ArrayBuffer
. Falling back to base64
encoding wasn’t an option. It was too slow and memory-heavy. The data was already in memory; we just needed to expose it directly.
This is where custom JSI bindings shine. Instead of unnecessary conversions, they give you access to the JS engine’s memory.
The Escape Hatch: RCTTurboModuleWithJSIBindings
React Native provides a protocol exactly for these scenarios. If your TurboModule implements RCTTurboModuleWithJSIBindings
, you can inject your own bindings when the module initializes.
Step 1. Declare conformance in your header
#import <ReactCommon/RCTTurboModuleWithJSIBindings.h>
@interface MyCustomModule <RCTTurboModuleWithJSIBindings>
@end
Step 2. Implement the protocol method
- (void)installJSIBindingsWithRuntime:(facebook::jsi::Runtime &)rt
callInvoker:(const std::shared_ptr<facebook::react::CallInvoker> &)jsInvoker {
rt.global().setProperty(runtime, "__MyCustomGlobal", "Hello World!");
}
This gives you direct access to the JSI runtime and call invoker.
Example: Passing an ArrayBuffer
to Native
With JSI, we can create a native function that directly accepts an ArrayBuffer
from JavaScript without any serialization overhead. We can inject a global function, __native__processBytes__(arrayBuffer)
, that can access the raw data.
Here’s how you can do it, step-by-step, inside the installJSIBindingsWithRuntime
method:
Step 1: Define the Host Function
First, create a JSI function from a C++ lambda. This function will be callable from JavaScript. The body of the function is where you'll add the logic to handle the ArrayBuffer
.
auto processFunc = jsi::Function::createFromHostFunction(
rt,
jsi::PropNameID::forAscii(rt, "__native__processBytes__"),
1, // expects one argument (arrayBuffer)
[](jsi::Runtime &rt, const jsi::Value &thisVal, const jsi::Value *args, size_t count) -> jsi::Value {
// function body goes here
return jsi::Value::undefined();
});
Let's quickly break down the arguments passed to createFromHostFunction
:
rt
: This is a reference to the JSIRuntime
, the execution context for the JavaScript world. You need it to create and interact with any JS values.jsi::PropNameID::forAscii(...)
: This creates an identifier for the function's name. JSI uses this ID for faster lookups than using raw strings.1
: This is theparamCount
, telling the JSI runtime that your function expects one argument from JavaScript.[](...) { ... }
: This is the C++ lambda that acts as the Host Function. It's the native code that runs when your function is called from JavaScript. It receives its own arguments to access the runtime, the JSthis
context, and the arguments passed from JS.
Step 2: Validate Input and Access Raw Data
Inside the lambda, validate that the first argument is an ArrayBuffer
.
if (count < 1 || !args[0].isObject() || !args[0].asObject(rt).isArrayBuffer(rt)) {
throw jsi::JSError(rt, "First argument must be an ArrayBuffer");
}
Then, get a direct, zero-copy pointer to its underlying data.
auto arrayBuffer = args[0].asObject(rt).getArrayBuffer(rt);
const void *data = arrayBuffer.data(rt);
size_t size = arrayBuffer.size(rt);
NSLog(@"Received ArrayBuffer of size: %zu", size);
Step 3: Register the Function Globally
Finally, attach your newly created JSI function to the global JavaScript object, making it accessible as global.__native__processBytes__
.
rt.global().setProperty(rt, "__native__processBytes__", std::move(processFunc));
Step 4: Call it from JavaScript
const bytes = new Uint8Array([1, 2, 3, 4]);
global.__native__processBytes__(bytes.buffer);
This demonstrates the core concept: accessing JavaScript's memory directly from native code.
For a more advanced example covering handling array buffers and returning a promise, you can refer to the code inside React Native AI.
Why This Matters
TurboModules cover most native integration needs. But when you hit an edge case, such as unsupported type or advanced optimizations, JSI bindings give you the flexibility to:
- Expose custom global functions
- Work with raw JS engine objects
- Bridge async native APIs directly into Promises
- Optimize data transfer without serialization
Array Buffers are coming to Turbo Modules
We're actively working on bringing first-class ArrayBuffer
support directly to TurboModules. This edge case is very popular, so we decided to bring a solution to everyone, allowing you to use a much simpler CodeGen instead.
Internally, we're huge fans of the new architecture and TurboModules and continue to be on the lookout for further improvements.
Closing Thoughts
If TurboModules are the safe, paved road, JSI is the open terrain. You rarely need it, but knowing how to reach for it unlocks the full potential of React Native’s native layer.
At Callstack, we’ve used these techniques in real-world projects where performance and low-level access matter. If you’re facing a similar edge case, let’s talk.
Learn more about
Performance
Here's everything we published recently on this topic.
We can help you move
it forward!
At Callstack, we work with companies big and small, pushing React Native everyday.
React Native Performance Optimization
Improve React Native apps speed and efficiency through targeted performance enhancements.
Monitoring & Observability
Enable production-grade monitoring and observability for React Native apps with real-time insights and alerts.
Release Process Optimization
Ship faster with optimized CI/CD pipelines, automated deployments, and scalable release workflows for React Native apps.
React Compiler Implementation
Use React Compiler to achieve instant performance benefits in your existing applications.
