Writing direct, synchronous C++ bindings without the bridge
The JavaScript Interface, JSI, connects the JavaScript runtime and native C++ code directly, without JSON serialization or an asynchronous bridge round trip. Building a HostObject shows how synchronous native bindings work in practice, why the performance gain matters mainly for frequent calls, and where the limits of this approach lie.
Table of Contents
- 1. Why the classic bridge was too slow for many use cases
- 2. What JSI really is: a C++ interface between runtime and native code
- 3. HostObject: exposing your own objects directly in the JS runtime
- 4. A practical example: a synchronous HostObject implementation
- 5. Installation: registering JSI modules through TurboModuleManager or a custom installer
- 6. Memory management: shared_ptr, runtime lifecycle and GC interop
- 7. Performance in practice: bridge versus JSI for frequent calls
- 8. Limits of JSI: thread safety and when async still makes sense
- 9. Debugging JSI code: native crashes and stack traces
- 10. Summary
- 11. FAQ
1. Why the classic bridge was too slow for many use cases
In the original React Native architecture, every call between JavaScript and native code ran through the bridge, an asynchronous channel that serialized each call including its arguments into a JSON-compatible format, batched it and sent it across a message queue to the respective other thread. For rare calls like a one-off API request that was unproblematic, but for functions called hundreds of times per second, for example animation values or sensor data, the overhead from serialization, queueing and thread switching added up noticeably.
A second structural problem was that the bridge was fundamentally asynchronous. Even a trivial, synchronous computation on the native side had to be modeled as a promise or callback, which added unnecessary complexity to JavaScript code and made real synchronous APIs, as native platform SDKs often offer, practically impossible without resorting to risky workarounds like synchronous methods on NativeModules, which only worked in a limited way on Android anyway.
2. What JSI really is: a C++ interface between runtime and native code
The JavaScript Interface, JSI for short, is not a new bridge but a C++ abstraction layer that lets native code talk directly to the JavaScript runtime, whether that runtime is Hermes, JavaScriptCore or another JSI-compatible engine. Instead of serializing data, JSI keeps references to actual JavaScript objects and functions in memory and lets native C++ code read, mutate or call these objects directly, the same way JavaScript itself would.
That makes synchronous calls structurally possible for the first time: a C++ function can be called from JavaScript and immediately return a value, with no promise, callback or bridge round trip at all. That is the technical foundation both TurboModules and Fabric are built on, but JSI can also be used completely independently to write custom, high-performance native bindings.
3. HostObject: exposing your own objects directly in the JS runtime
The central building block for custom JSI bindings is the jsi::HostObject class. Subclassing it and overriding the get and set methods lets you attach a C++ object into the JavaScript runtime that behaves like a normal object to JavaScript code, but whose properties and methods actually execute in C++. Every property access from JavaScript synchronously calls the corresponding C++ method.
This suits cases where native computation or native state needs to be available from JavaScript with no noticeable latency, for example a cryptography module, an image processing kernel, or a high-performance data structure like a ring buffer for sensor data. Unlike a classic native module, no additional bridge call is incurred per access.
4. A practical example: a synchronous HostObject implementation
A simple example is a counter that lives entirely in C++ and can be synchronously read and incremented from JavaScript. The constructor implements a get method that reacts to the requested property name, plus optionally a set method if write access should be allowed. Installation happens through a global property on the jsi::Runtime object, usually at app startup.
The example below shows a minimal HostObject class with a readable property and a callable method, both implemented directly in C++, with no bridge call in the background at all.
class CounterHostObject : public jsi::HostObject {
public:
explicit CounterHostObject(int start) : value_(start) {}
jsi::Value get(jsi::Runtime &rt, const jsi::PropNameID &name) override {
auto propName = name.utf8(rt);
if (propName == "value") {
return jsi::Value(value_);
}
if (propName == "increment") {
return jsi::Function::createFromHostFunction(
rt, name, 0,
[this](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *, size_t) {
value_ += 1;
return jsi::Value(value_);
});
}
return jsi::Value::undefined();
}
private:
int value_;
};
// Installed once at app startup, e.g. in a custom JSI installer
void installCounter(jsi::Runtime &runtime) {
auto counter = std::make_shared<CounterHostObject>(0);
runtime.global().setProperty(
runtime, "NativeCounter",
jsi::Object::createFromHostObject(runtime, counter));
}
5. Installation: registering JSI modules through TurboModuleManager or a custom installer
For production projects it pays off to register a custom JSI module either through the existing TurboModule mechanism, if it fits into the regular module system, or through a dedicated, minimal installer that runs directly when the JavaScript runtime is created. The latter is common for very early, cross-platform low-level bindings, for example logging or performance measurement, that need to be available before the actual JavaScript bundle even loads.
It is important to hook the installer into both the iOS and the Android runtime initialization, since both platforms have separate entry points for runtime creation. Forgetting one platform produces an undefined error on the first access to the global object, which is hard to debug without knowing the installation order.
6. Memory management: shared_ptr, runtime lifecycle and GC interop
JSI objects live in two worlds at once: as C++ objects with classic, reference-counted lifetime through std::shared_ptr, and as values managed by the JavaScript garbage collector inside the runtime. A HostObject stays alive as long as at least one JavaScript reference to it exists, which means cyclic references between HostObjects and JavaScript functions can leak memory unless weak references are used deliberately.
A common mistake is capturing a this reference by copy instead of through a shared_ptr inside a host function lambda, which can leave the underlying C++ object already destroyed while JavaScript still holds a valid reference to it. Such bugs usually show up as sporadic crashes far away from the actual source of the error and are hard to reproduce without a clean ownership structure.
7. Performance in practice: bridge versus JSI for frequent calls
The performance difference between bridge and JSI shows up most clearly for functions called very frequently with small amounts of data. A bridge call, regardless of the actual payload, always costs the overhead of JSON serialization, queueing and at least one thread switch, while a JSI call through a HostObject only costs the actual function execution, since no data format needs to be converted.
At thousands of calls per second, for example an animation loop querying several native values per frame, that is the difference between a smooth 60 hertz animation and noticeable jank. For rare calls, say a one-off file access, the difference is barely measurable, which is why JSI pays off mainly for hot-path code, not for every native binding.
8. Limits of JSI: thread safety and when async still makes sense
JSI calls run synchronously on the thread they are called from, usually the JavaScript thread. That means a long-running operation inside a HostObject blocks the entire JavaScript thread and freezes the UI if triggered from the main thread. For compute-heavy tasks like large-scale image processing, a dedicated background thread with an asynchronous callback remains the right choice, not a direct synchronous JSI call.
In addition, access to a jsi::Runtime object itself is not thread-safe, since the runtime is designed for exactly one thread. Anyone wanting to return values to JavaScript from a background thread must make that switch explicitly through a mechanism intended for it, such as runOnJSQueueThread, instead of addressing the runtime directly from an arbitrary thread.
9. Debugging JSI code: native crashes and stack traces
Bugs in JSI code often do not show up as a normal JavaScript exception but as a native crash with a C++ stack trace that is hard to read without symbolication. On iOS, a debugger attached through Xcode with debug symbols enabled helps, on Android the native crash handler combined with ndk-stack or symbolication through Play Console crash reports produces usable stack traces.
A typical class of bugs is invalid type conversion, for example trying to read a JSI value as a number when JavaScript actually passed undefined. Since JSI deliberately offers little automatic error handling to avoid overhead, it pays off to consistently check the actual value type at the boundaries of a custom HostObject before processing it further, instead of relying on an implicit exception.
| Criterion | Classic bridge | JSI HostObject | Practical consequence |
|---|---|---|---|
| Call type | Exclusively asynchronous | Synchronous calls possible | Immediate return values without a promise |
| Data transfer | JSON serialization | Direct object references | No conversion overhead |
| Threading | Bridge queue on a separate thread | Runs on the calling thread | Long operations can block the UI if misused |
| Type safety | Loose, runtime based | Explicit jsi::Value type checks needed | Developer must guard types themselves |
| Use case | Rare, non-critical calls | High-frequency hot-path calls | JSI is not worth it for every binding |
Mironsoft
React Native app development and Magento integration
A mobile app for the Magento shop that actually runs smoothly?
We build React Native apps cleanly connected to the Magento REST or GraphQL API, from the first line of code to publishing on the App Store and Google Play.
App Concept
Plan the architecture and feature scope of a Magento-connected app together.
Magento API Integration
Cleanly connect product catalog, cart, and checkout to the shop API.
Store Publishing
Guide the App Store and Google Play release process without pitfalls.
10. Summary
JSI Direct Bindings at a Glance
JSI at its core
A C++ interface that connects the JavaScript runtime and native code directly, without serialization.
HostObject
Lets custom C++ objects behave like normal JavaScript objects with synchronous methods.
Performance
Pays off mainly for very frequent calls with small payloads, barely measurable for rare calls.
Limits
Synchronous calls block the calling thread, long-running work still belongs on a background thread.