A React Native barcode-scanner library moves from bridge events to a JSI HostObject: what changes, and what threading rule must its C++ code obey?
answer
- reads become synchronous
- get, set, getPropertyNames
- no serialization per detection
- one thread at a time
- CallInvoker hops to JS
basics
~20 sScans stop being serialized, queued bridge events: JavaScript holds a C++ host object and reads its state synchronously. The rule: jsi::Runtime must not be used concurrently, so the camera thread schedules work onto the JS thread instead of touching the runtime.
solid answer
~40 sWith the bridge, every detection was converted into a JSON-compatible event, queued and delivered asynchronously, and JavaScript could only ask for state through a promise. As a `jsi::HostObject`, the scanner becomes a live C++ object in JavaScript: reading `scanner.lastCode` calls the subclass's `get` synchronously on the JS thread, and nothing is serialized until JavaScript asks. The price is thread safety. A `jsi::Runtime` must not be used from several threads at once, so the camera thread may only update plain C++ state under a lock and then schedule JavaScript work with the module's `CallInvoker` (`invokeAsync`). `get` must stay cheap because JavaScript waits on it, and the destructor runs from the garbage collector on an arbitrary thread, so it must not touch the runtime.
go deeper
Know that a JSI host object lets JavaScript read native state directly instead of waiting for a bridge event.
Explain get, set and getPropertyNames on a HostObject, and why removing per-event serialization matters for a continuous scanner.
Enforce the threading rule: no runtime access off the JS thread, CallInvoker for callbacks, locked shared state, cheap get, and a GC-safe destructor.
Decide between a raw host object and a spec-generated Turbo Native Module by weighing live stateful access against the safety of generated, typed glue.
## The starting point: a bridge-era scanner Picture a barcode-scanner library written for React Native's legacy architecture. The camera pipeline detects a code on a native thread, and the library sends it to JavaScript as an **event** through the bridge: - every detection becomes a message whose payload (the code, its format, a timestamp) is converted into JSON-compatible data; - the message waits in the bridge queue and reaches JavaScript asynchronously, batched with other traffic; - in this library's bridge API, JavaScript cannot ask "what is the last code?" and get an immediate answer; it calls a method and waits for a callback or a promise; - scanning continuously means a steady stream of serialized messages. ## The JSI design On the New Architecture, the library can expose a **host object** instead. A host object is a C++ subclass of `jsi::HostObject`; JavaScript sees an ordinary object, but its property reads call the subclass's `get` method, writes call `set`, and enumeration calls `getPropertyNames`. It is exposed with `jsi::Object::createFromHostObject`, either returned from a Turbo Native Module method or installed on the runtime's global object. ```cpp class ScannerHostObject : public jsi::HostObject { public: explicit ScannerHostObject(std::shared_ptr<react::CallInvoker> jsInvoker) : jsInvoker_(std::move(jsInvoker)) {} jsi::Value get(jsi::Runtime& rt, const jsi::PropNameID& name) override { if (name.utf8(rt) == "lastCode") { std::lock_guard<std::mutex> lock(mutex_); if (lastCode_.empty()) return jsi::Value::null(); return jsi::String::createFromUtf8(rt, lastCode_); } return jsi::Value::undefined(); } // Called by the camera pipeline on its own thread. void onCodeDetected(std::string code) { { std::lock_guard<std::mutex> lock(mutex_); lastCode_ = code; } jsInvoker_->invokeAsync([code](jsi::Runtime& rt) { // Now on the JS thread: safe to call a stored JS listener here. }); } private: std::shared_ptr<react::CallInvoker> jsInvoker_; std::mutex mutex_; std::string lastCode_; }; ``` What changes for the JavaScript caller: | Concern | Bridge events | JSI host object | |---|---|---| | Reading the last code | Call a method, wait for a promise | `scanner.lastCode`, answered synchronously | | Payload | Serialized on every detection | C++ builds a JS string only when asked | | Native state | Copied into messages | JavaScript holds a reference to the live C++ object | | Delivery | Queued and batched | Direct call, or one scheduled callback per detection | ## The threading rule A `jsi::Runtime` **must not be used from multiple threads concurrently**. JSI's own header says so, and the runtime has no internal lock for you. Three consequences for the scanner: 1. **`get` and `set` run on whichever thread is executing JavaScript**, normally the JS thread, because JavaScript is the caller. They may use the runtime, but they run while JavaScript waits, so they must be quick and must not block on the camera. 2. **The camera thread must never touch the runtime.** It must not create a `jsi::String` or call a stored `jsi::Function` there. It updates plain C++ state under a mutex and then schedules work onto the JS thread with the module's `CallInvoker` (`jsInvoker_->invokeAsync(...)`), whose callback receives the `jsi::Runtime&` on the right thread. 3. **The host object's destructor runs when the garbage collector finalizes the JavaScript object**, on a thread you do not control and possibly as late as runtime shutdown. It must not use the runtime or do expensive work; anything nontrivial goes onto a queue you manage. Shared state between the camera thread and `get` needs its own synchronization, which is why the example guards `lastCode_` with a mutex. ## Choosing the right level - A **typed Turbo Native Module** covers most of this with less risk: synchronous methods, promises, and events generated from a spec. Reach for a raw host object when you need a live, stateful native object in JavaScript, not just a set of methods. - Keep synchronous surfaces **cheap reads**. Starting the camera, requesting permission or decoding an image stays asynchronous. - Deliver high-frequency results **sparingly**: coalesce duplicate detections in C++ before scheduling anything onto the JS thread, because each callback still costs JS-thread time. The interview signal is that you name both halves: the host object removes serialization and makes reads synchronous, and the price is owning thread safety that the bridge used to hide behind its queue.
- Why must a React Native jsi::HostObject destructor avoid calling into the runtime?The destructor runs when the garbage collector finalizes the JavaScript object that wraps it, which can happen on a thread you do not control and as late as runtime shutdown. JSI's header says it is unsafe to perform operations that need the runtime there. Release C++ resources only, and move any JavaScript work or expensive cleanup onto a queue you manage.
- When is a typed Turbo Native Module a better choice than a raw JSI HostObject in a React Native library?When the surface is a set of methods, events and promises rather than a live stateful object. A Turbo Native Module generated from a spec gives you JSI's synchronous calls with type checking and generated glue, so there is less hand-written runtime code to get wrong. A raw HostObject earns its place when JavaScript must hold and read a native object's state directly.
saying these in an interview costs you the question
- The camera thread can call a stored jsi::Function directly when a code is found.
- jsi::Runtime locks itself, so any thread may use it safely.
- A HostObject's get method may block waiting on the camera.
- A HostObject's destructor always runs on the JS thread.
- Switching to JSI makes JavaScript run on the camera thread.