React.memo cannot fix a C++ deserialization lock
By Yuvi, Founder & Technical Lead
View code & run locally Zetesis-dev/jsi-thread-lock A browser reconstruction with synthetic data. Compare the blocking load, three frontend fixes, chunked work, and a background worker. (opens in a new tab)A site manager taps a project and waits. Tapping again gets no response. About ten seconds later, the app catches up and starts handling the input that arrived while it was stuck.
This was an offline-first app for field work. It kept blueprints and thousands of nested inspection forms available where the signal was unreliable. Small projects loaded without trouble. Large ones could leave someone waiting on a screen they could see but could not use.
The confusing part was that an animation could keep moving through the freeze. The app looked alive. The rendering, list, and query checks I started with did not explain why it would not answer a tap.
An animation can outlive a responsive interface Link to An animation can outlive a responsive interface
React Native's JavaScript thread and native UI thread can be busy with different things. An animation using the native driver can continue after it has started, even while JavaScript is blocked. A tap that needs a JavaScript handler still has to wait.
That distinction explained the strange combination I was seeing. Motion on the screen told me part of the app was still running. It did not tell me whether JavaScript could take another turn.
I needed to find what was occupying that thread for the whole loading interval. My first attempt at getting help sent me looking somewhere else.
Three fixes that never reached the bottleneck Link to Three fixes that never reached the bottleneck
I described the symptom to an AI assistant, keeping the details anonymous to
stay inside the client's NDA: a React Native app freezing while loading large
offline Firestore collections over JSI. It suggested memoizing the list items,
deferring rendering with InteractionManager, and moving complex state into
Redux.
Each suggestion left the expensive loading operation in place:
React.memocan skip unnecessary component renders. The synchronous object construction was already blocking the thread before those components could use the data.InteractionManagerwas suggested to defer rendering until animations finished. Deferring that rendering would leave the same synchronous load occupying the JavaScript thread.- Redux could change how the app organized its state. Moving state into a store would not change the work required to construct the incoming objects.
I had asked about a frozen interface, and the advice stayed close to the interface. I had not established where the time was going. That made it easy to accept familiar performance techniques without checking whether they could affect the operation that was actually blocking input.
One project, one uninterrupted allocation spike Link to One project, one uninterrupted allocation spike
Memory profiling gave me a more useful lead. During the freeze, allocations climbed in one continuous block. I wanted to understand what was being created in that interval, before spending more time changing the components that would eventually display it.
We had recently moved to React Native's Bridgeless architecture. The old bridge serialized and queued calls between JavaScript and native code. JSI allows direct calls across that boundary, but it does not automatically move expensive work onto a background thread. The New Architecture supports both synchronous and asynchronous work. The loading path in this app was synchronous.
Opening a project materialized its entire Firestore collection as JavaScript objects in one pass. Thousands of deeply nested inspection forms were being constructed inline on the JavaScript thread. Until that call returned, the thread could not handle the waiting input.
That was the missing explanation for the ten-second pause. Reducing the number of component renders would leave the same large object graph to be constructed before rendering could begin.
Let JavaScript ask for the objects it needs Link to Let JavaScript ask for the objects it needs
I changed what happened before the data reached JavaScript. A TurboModule handled chunking and parsing on a native background thread. Across the JSI boundary, I passed primitive reference IDs. Full objects were materialized when the app needed them.
The loading path changed from this:
Open project
-> construct the full object graph on the JavaScript thread
-> return control so JavaScript can handle input again
To this:
Open project
-> chunk and parse on a native background thread
-> pass reference IDs to JavaScript
-> materialize individual objects as needed
JavaScript still had work to do. It no longer had to construct every nested form up front just to open a project. Keeping the initial transfer small was as important as moving the parsing: returning the entire object graph at the end would have reintroduced a large amount of work on the receiving side.
Getting from the diagnosis to the fix took a couple of hours. Much of that time went into reading Stack Overflow discussions and GitHub issue comments from people who had encountered similar native-boundary problems.
What fell from ten seconds to 30 milliseconds Link to What fell from ten seconds to 30 milliseconds
I compared the same heavy project on the same device with the same data, measuring how long it took for the interface to become responsive again.
| Loading path | Time before input could respond again |
|---|---|
| Synchronous object construction on the JavaScript thread | Over 10 seconds |
| Background parsing with reference IDs passed to JavaScript | About 30 milliseconds |
Those are observations from this project's test, not a general React Native benchmark or a promise about another device. They describe the interruption to input, not the time required to finish parsing and materialize every object.
The parsing work still existed. Most of it had moved off the thread responsible for responding to the user, and the app stopped demanding every full object at once. That is why responsiveness could improve so much without a comparable improvement in parsing speed.
Make the freeze visible in your browser Link to Make the freeze visible in your browser
The companion repository reconstructs the scheduling problem with synthetic data. It uses the browser's main thread and a Web Worker to demonstrate the same blocking pattern. It does not contain the client's app or reproduce its native implementation and device timings.
With Python 3.12 or newer, run:
git clone https://github.com/Zetesis-dev/jsi-thread-lock
cd jsi-thread-lock
python run.py
Start with the blocking load and watch the JavaScript-driven indicator beside the CSS spinner. JavaScript stops updating while the compositor-driven spinner keeps moving. Then enable the three frontend fixes and compare the longest blocking span. Finally, try the chunked and background-worker paths.
That comparison is the part I wish I had seen earlier: the screen can keep moving while the thread that handles your input is occupied.