How did React Native's legacy bridge batch JavaScript-to-native calls in its MessageQueue, and why did the arguments have to be serializable?
answer
- three parallel arrays
- callbacks become numeric ids
- flushed on the way back
- 5 ms immediate flush
- folly::dynamic on the native side
basics
~20 sMessageQueue recorded each call as a module id, method id and arguments, and handed the batch to native code on the next native-to-JS return or after 5 ms. Arguments crossed as JSON-compatible data, so functions, NaN and native objects could not.
solid answer
~40 sIn the legacy architecture, `MessageQueue.enqueueNativeCall` pushed a module id, a method id and the arguments onto three parallel arrays, and replaced callbacks with numeric call ids kept on the JavaScript side. The queue usually left as the return value of the next native-to-JavaScript call (`callFunctionReturnFlushedQueue`), or immediately through `nativeFlushQueueImmediate` if 5 ms had passed since the last flush. Native code converted it into `folly::dynamic`, parsed the method calls and ran them on each module's own queue. Because everything went through that JSON-compatible form, arguments had to be plain data: no functions beyond the trailing callbacks, no `NaN` or `Infinity`, no native references. Results came back asynchronously by invoking a stored call id, which is why the cost was latency, serialization work and state drift.
go deeper
Recall that the old bridge queued calls and sent them in batches as plain data, and that answers came back later through callbacks.
Walk through enqueueNativeCall: the three arrays, callback ids, the flush on return or after 5 ms, and why only JSON-compatible arguments survive.
Tie the queue's design to the production symptoms it caused, such as blank list frames and chatty modules, and to how JSI removes them.
Use the bridge's trade-off, fewer crossings for more latency, to reason about any batching boundary you design between subsystems.
## The bridge as a message queue In React Native's legacy architecture, JavaScript never called native code directly. It wrote down what it wanted and handed the list over later. The JavaScript side of that channel lives in `Libraries/BatchedBridge/MessageQueue.js`, and its design explains most of the old architecture's limits. When JavaScript called a legacy native module method, `MessageQueue.enqueueNativeCall` did four things: 1. **Registered callbacks.** Any success or failure callback was stored in a JavaScript map under a numeric **call id**; only the number travelled to native code. 2. **Queued the call.** The module id, the method id and the arguments were pushed onto three parallel arrays that together form the queue. 3. **Checked the arguments (development only).** In `__DEV__`, arguments that could not be converted, such as a function or a non-finite number, failed with an invariant, and the arguments were deep-frozen so they could not be mutated after being queued. 4. **Maybe flushed.** If at least `MIN_TIME_BETWEEN_FLUSHES_MS` (5 ms) had passed since the last flush, JavaScript pushed the queue to native immediately through `nativeFlushQueueImmediate`. ## When the queue reached native code Most of the time the queue did not leave on its own. It went back **as the return value** of the next call from native into JavaScript: - native code delivered an event or a callback result by calling `callFunctionReturnFlushedQueue` or `invokeCallbackAndReturnFlushedQueue`; - JavaScript ran the function, then returned `flushedQueue()`, everything queued in the meantime; - the native side converted that JavaScript value into `folly::dynamic` (a JSON-like C++ value), parsed it into method calls and dispatched each one to its module, on the module's own thread or queue. So one "round" of the bridge carried many calls in each direction. That is the **batching**: fewer boundary crossings, at the price of latency. ## Why arguments had to be serializable Everything in the queue had to survive conversion into JSON-compatible data: | Allowed | Rejected or lost | |---|---| | strings, booleans, `undefined`, `null` | functions, except the trailing callbacks, which became call ids | | finite numbers | `NaN` and `Infinity` | | arrays and plain objects of the above | class instances, native object references | Queued calls could not return a value either. A native method answered by invoking one of those stored call ids in a later message, which is why bridge-era APIs are callback- or promise-shaped. A separate, rarely used blocking-synchronous method type skipped the queue through `callNativeSyncHook`, at the cost of stalling the JS thread until native code returned. ## What batching cost - **Latency**: a queued call and its answer needed at least one trip through the queue each way, so reading native state meant waiting. - **Serialization work**: large or frequent payloads (scroll events, long arrays) were converted on every crossing, which became a throughput bottleneck. - **Drift**: JavaScript and native state could only be reconciled asynchronously, so intermediate states could render, such as empty list space during fast scrolling. - **Head-of-line blocking**: a burst of messages delayed everything queued behind it. In development, `MessageQueue.spy(true)` logged every message in both directions, which was the classic way to find a chatty module. The usual fixes followed from the design: send fewer, larger messages (one summary instead of per-frame events), keep payloads small, and move work that needed tight coordination with native views into native code entirely, so it never had to cross the queue at all. ## Where this stands in React Native 0.87 The New Architecture replaced this path with JSI: Turbo Native Modules and the Fabric renderer call C++ directly, without a queue or serialization. The New Architecture is the only architecture since React Native 0.82, and bridge-era classes are being removed release by release (legacy Android bridge classes such as `CallbackImpl` and `CxxModuleWrapper` went in 0.84; the legacy C++ bridge code is guarded by `RCT_REMOVE_LEGACY_ARCH`). You meet `MessageQueue` today mainly when reading old library code or explaining why the New Architecture exists, and that is how interviewers use it: as the "before" that makes JSI's benefits concrete.
- In React Native's legacy bridge, how did a native module return a result to JavaScript?An ordinary, queued method could not return one directly. When JavaScript made the call, MessageQueue stored the success and failure callbacks under a numeric call id and sent only that id. Later, native code called back into JavaScript with `invokeCallbackAndReturnFlushedQueue`, passing the id and the result, and MessageQueue looked up and ran the stored callback. Promise-returning methods were built on the same pair of callbacks.
- How would you find a React Native legacy-bridge module that floods the queue with messages?In development, `MessageQueue.spy(true)` logged every message in both directions, with module and method names, so a module firing hundreds of calls per second stood out. A trace also showed a counter of pending JavaScript-to-native calls. The usual fix was to throttle or batch at the source, for example sending one summary instead of per-frame events.
saying these in an interview costs you the question
- Each bridge call crossed to native code the instant it was made.
- A queued bridge call returned its native result as the call's return value.
- Functions and class instances crossed the bridge by reference.
- Batching made bridge calls lower-latency than direct calls.
- MessageQueue is still how a React Native 0.87 app calls native modules.