Where React Native Latency Hides: Boundary Crossings, Not Slow Code
Learn why fast APIs and memoized components can still feel sluggish in React Native, and how batching, throttling and JSI-era design cut cross-runtime cost.
This article is published in English.
Sources: The Hidden Latency Cost in React Native Bridge Architectures
A React Native screen can feel sluggish even when every piece you measure looks healthy: the API answers in about 100 ms, the queries are indexed, components are memoized and images come from cache. Yet a tap takes a beat too long to register, a bottom sheet hitches, map markers trail behind reality, an animation that is flawless on a flagship phone stutters on a budget Android device, and a burst of sensor events locks up the interface. In many of these cases the missing time is spent in a place teams rarely instrument: the handoff between the JavaScript runtime and native code.
This guide explains how that handoff worked under the legacy Bridge, why its cost compounds instead of appearing in any single call, what the New Architecture (JSI, TurboModules, Fabric and the redesigned renderer) actually changes, and which design habits keep cross-runtime traffic cheap no matter which architecture you run. The New Architecture has been the default since React Native 0.76, but the Bridge is still worth understanding: plenty of production apps carry legacy modules, interop layers, older libraries and patterns that were shaped by it. More importantly, deleting the Bridge does not make poorly designed communication between runtimes free.
Two execution environments in one app
A useful mental model is that a React Native app runs in at least two worlds. Your components, state, business rules and networking live on the JavaScript side; everything that touches the operating system lives on the native side.
┌──────────────────────────────┐
│ JavaScript / TS │
│ │
│ React Components │
│ State Management │
│ Business Logic │
│ API Handling │
└──────────────┬───────────────┘
│
│ JS ↔ Native communication
│
┌──────────────▼───────────────┐
│ Native Side │
│ │
│ Android / iOS APIs │
│ Camera │
│ Bluetooth │
│ GPS │
│ Storage │
│ Native Views │
│ Sensors │
└──────────────────────────────┘
React code may run in JavaScript, but almost every meaningful feature eventually needs the platform. Typical reasons to reach across include:
- rendering native UI views,
- opening the camera,
- reading GPS coordinates,
- accessing the file system,
- triggering haptics,
- talking to Bluetooth devices,
- using secure storage,
- calling into third-party native SDKs,
- receiving sensor readings,
- invoking other platform-specific APIs.
So data moves between the two environments constantly. In the classic architecture, all of that traffic went through the React Native Bridge.
How the legacy Bridge moved a call
The original design deliberately kept JavaScript execution and native execution apart. A single round trip looked roughly like this:
JavaScript
↓
Prepare parameters
↓
Serialize data
↓
Queue bridge message
↓
Cross execution boundary
↓
Native receives message
↓
Native performs operation
↓
Prepare result
↓
Serialize result
↓
Queue response
↓
JavaScript receives result
The defining property is that the whole exchange was asynchronous. Arguments were serialized, placed in a queue, carried across the boundary, deserialized and handled on the native side, and any result made the same trip back. The React Native team's own New Architecture material names serialization and asynchronous bridging as core limitations of the old model.
For occasional calls this overhead is harmless. Writing a string to the clipboard is a good example:
await Clipboard.setString("Hello");
Whatever the crossing costs here, no user will ever perceive it. Trouble starts when the boundary becomes a high-frequency channel that the app leans on continuously.
One call is cheap; the pattern is what hurts
Discussions of Bridge performance often imply that each call was prohibitively expensive. That framing misses the point. Real slowdowns usually come from the combination of several factors multiplying together:
frequency × payload size × scheduling pressure × synchronization requirements
Assume a single crossing costs almost nothing. Now scale the count:
1 call
10 calls
100 calls
1,000 calls
10,000 calls
Then make each payload bigger, fire updates continuously while the user is scrolling, or require a Native → JavaScript → Native round trip before the next visual state can be drawn. A cost that was negligible in isolation is now sitting on the critical rendering path. No individual function looks slow in a profiler; the latency is spread across the system, which is exactly why it stays hidden.
Treat interaction latency as a pipeline
It helps to write down every stage an interaction passes through:
Total Latency =
JS Work
+ Serialization
+ Queue Waiting
+ Context Boundary Cost
+ Native Work
+ Return Communication
+ Rendering Delay
Teams tend to tune one term in isolation, typically the native work:
Native Work
or the JavaScript work:
JS Work
The user, however, only ever experiences the sum:
TOTAL LATENCY
Consider a native operation that completes in:
2 ms
On its own that is an excellent number. But the full breakdown might be:
JS preparation 2 ms
Serialization 2 ms
Queue waiting 4 ms
Native operation 2 ms
Response communication 3 ms
React update 4 ms
--------------------------------
Total 17 ms
The "2 ms operation" has turned into a roughly 17 ms interaction. The React Native docs describe a frame budget of about 16.67 ms at 60 FPS; overrun it and a frame may be dropped. Many current phones refresh faster than 60 Hz, which shrinks the budget even further.
Frame budgets leave little room for overhead
At 60 Hz the arithmetic is:
1000 ms / 60 ≈ 16.67 ms
That gives you roughly
16.67 ms
to produce each frame. At 120 Hz it halves:
1000 ms / 120 ≈ 8.33 ms
About eight milliseconds is very little. If a few of them go to shuttling data back and forth between runtimes for no good reason, the architecture itself is eating the rendering budget. The symptoms are familiar:
- stuttering animations,
- gestures that respond late,
- sluggish navigation transitions,
- dropped frames,
- delayed touch feedback,
- uneven scrolling,
- UI updates that land one frame after they should.
The performance docs also separate JavaScript thread FPS from UI (main) thread FPS. When the JS thread is saturated, it cannot process touch events or JavaScript-driven UI updates promptly, while interactions that are driven natively, such as scrolling a native list, can remain smooth. That split explains a classic debugging puzzle: one part of the screen feels fluid while another feels frozen.
Scenario: streaming location into a map
Picture a ride-hailing app receiving a continuous stream of GPS fixes. The straightforward wiring looks like this:
Native GPS
↓
Bridge
↓
JavaScript
↓
State Update
↓
React Render
↓
Bridge
↓
Native Map
↓
Marker Position Updated
Now run that loop for every fix. Neither computing the position nor moving a marker is expensive by itself, but the design has built a repeating round trip:
Native → JS → Native
It runs over and over for as long as the screen is open. Show many drivers at once and the traffic multiplies:
Driver 1 position
Driver 2 position
Driver 3 position
Driver 4 position
...
Driver 100 position
Emitting a hundred tiny updates one by one can be far more costly than emitting a single, well-prepared batch describing all of them. The root cause is the shape of the data flow, not React's rendering.
Scenario: sensor streams
Accelerometer and gyroscope data follow the same pattern. Suppose native code emits:
60 events / second
Every event crosses into JavaScript, gets processed, and may trigger a new update headed back to native UI. The result is a real-time pipeline threaded through the boundary:
Sensor
↓
Native
↓
JS
↓
Calculation
↓
React
↓
Native UI
At a low event rate this can work perfectly well. At a high rate, and especially on weaker hardware, work can start arriving faster than the app drains it, which introduces yet another hidden cost.
Queues that grow faster than they drain
Suppose events arrive every
10 ms
but handling each one effectively takes
15 ms
This is no longer just a slow operation; it is a backlog that grows without bound. Fresh readings arrive while stale ones are still waiting to be processed, so the UI is always showing the past. For real-time interfaces that is the worst possible outcome. A user rarely needs every intermediate sample; what they need is the most recent value that matters. Designs that coalesce or drop superseded events keep the queue short and the display current.
Chatty interfaces multiply crossings
This lesson is not specific to React Native; engineers who build distributed systems deal with it daily. Compare a module API that sets one field per call:
await nativeModule.setName(name);
await nativeModule.setEmail(email);
await nativeModule.setAge(age);
await nativeModule.setCountry(country);
await nativeModule.setLanguage(language);
with one that accepts the whole update at once:
await nativeModule.updateProfile({
name,
email,
age,
country,
language,
});
The second version needs far fewer trips across the boundary for the same result. The rule of thumb is short:
Do more work per crossing, and cross less often.
The same principle applies to:
- HTTP APIs,
- microservice calls,
- database access,
- worker threads,
- inter-process communication,
- mobile bridges,
- distributed systems in general.
In networking this is known as chatty communication. The legacy Bridge simply brought that classic distributed-systems issue inside a single mobile application.
Payload size is the second dimension
How often you cross is only half the story; how much you carry matters too. Consider pushing something like this across the legacy boundary:
{
users: [...10000Users]
}
The sender has to encode the structure, and the receiver has to rebuild and process it. Big arrays, deeply nested objects, image metadata, binary data and large configuration trees all add measurable overhead. The workloads most exposed to it are:
- camera frames,
- audio buffers,
- image processing,
- machine-learning inputs,
- large query results,
- complex animations,
- live maps,
- sensor streams.
The React Native architecture documentation uses an extreme but instructive case: real-time camera frame buffers, which can amount to tens of megabytes per frame and enormous volumes per second. Repeatedly serializing that kind of data through an asynchronous bridge is simply the wrong tool.
Why camera pipelines make the cost obvious
A single frame can hold megabytes of pixels. Now deliver frames at
30 FPS
or even
60 FPS
If every one of them must be converted into a JavaScript-friendly serializable form and sent through an asynchronous layer, the transport becomes the bottleneck. You want to avoid this shape on a per-frame basis:
Native Camera
↓
Huge serialized payload
↓
JavaScript
↓
Process
↓
Serialized result
↓
Native
That is why high-performance libraries increasingly keep heavy processing near native memory and hand JavaScript a narrow, efficient interface instead. That direction leads straight to one of the most significant changes in modern React Native.
What JSI changes
JSI (JavaScript Interface) lets JavaScript interact with native and C++ code much more directly. Rather than depending entirely on the serialize-and-queue path of the old Bridge:
JavaScript
↓
Serialize
↓
Bridge
↓
Deserialize
↓
Native
the New Architecture can expose native functionality through direct interfaces:
JavaScript
↓
JSI
↓
C++ / Native
According to the React Native docs, JSI allows JavaScript to hold references to C++ objects and C++ to hold references to JavaScript objects, so methods can be invoked without the Bridge's serialization model. Conceptually, calling a native method starts to resemble calling a function on a host object rather than posting a message to another process. That is a fundamental shift in the architecture.
Why JSI does not end performance work
A common misreading goes like this:
"JSI removes the Bridge, so the performance problems are gone."
They are not. The transport is much better, but poor system design still produces latency. A hundred thousand synchronous calls through JSI:
100,000 synchronous native calls
may each be cheaper than a Bridge call, yet most of that work is still unnecessary. The same holds for passing huge objects over and over, running CPU-heavy logic on the JavaScript thread, blocking the main UI thread, or rendering thousands of components that did not need to render. Synchronous access even introduces a new risk: a slow synchronous native call now blocks the JavaScript thread directly instead of resolving later.
So the takeaway is not "the Bridge was slow, JSI is fast." It is closer to this:
Every crossing between runtimes costs something. Structure the app so that unnecessary crossings never happen.
TurboModules: the modern native module system
TurboModules replace the legacy Native Modules system. The old modules were exposed through the Bridge; the new ones integrate more tightly through JSI and Codegen, which generates typed bindings from a spec. The benefits React Native lists include:
- synchronous native access when a use case requires it,
- type safety across the boundary,
- interoperability with C++,
- sharing code across platforms,
- lazy module loading.
Lazy loading deserves attention. In the old model, initializing native modules during startup could add work before the first screen appeared. With lazy loading, a module is initialized only when it is first used. Instead of:
Application starts
↓
Initialize everything
↓
Eventually show UI
startup can look more like:
Application starts
↓
Initialize required components
↓
Show UI
↓
Load additional modules when needed
Changes like this affect something users notice far more than benchmark numbers: how quickly the app feels ready to use.
Fabric: the rendering side of the redesign
The communication layer was not the only part rebuilt. Fabric, the new rendering system, moves more of the rendering infrastructure into shared C++ and enables capabilities that were awkward under the old asynchronous model, including:
- synchronous layout access where it is needed,
- better interoperability,
- multiple update priorities,
- support for modern React features,
- more flexible scheduling.
Priorities matter because interfaces do not treat every update the same way:
User presses button → HIGH priority
Background data refresh → LOWER priority
Analytics event → LOW priority
Animation → HIGH priority
Prefetching → LOW priority
A capable renderer should not handle a button press and an analytics ping as equally urgent. Prioritization is a foundation of responsive UI, and Fabric is what lets React Native take part in concurrent React's scheduling model. If you want to see how React itself encodes update priority, the article on how React lanes use bitmasks to encode priority covers the mechanics.
Boundary churn is the real enemy
If the whole problem had to be reduced to two words, they would be boundary churn: repeatedly passing small fragments of information back and forth between execution environments.
JS
↓
Native
↓
JS
↓
Native
↓
JS
↓
Native
When you see this shape, it usually means the responsibility for a feature has been split in the wrong place. The question to ask is where the work should actually live. Take audio processing. A churn-heavy design looks like this:
Native microphone
→ JS audio frame
→ calculate
→ native
→ next frame
→ JS
→ calculate
→ native
A better split keeps the high-frequency processing native and sends JavaScript only results worth acting on:
Native microphone
↓
Native processing
↓
Native processing
↓
Native processing
↓
Meaningful result
↓
JavaScript
The amount of computation may be similar, but the communication pattern is completely different, and so is the load on both threads.
Practical techniques for cheaper crossings
Batch operations into one call
A loop that awaits a native call per item is a common source of churn:
for (const item of items) {
await NativeModule.saveItem(item);
}
With 500 items, that is potentially hundreds of separate crossings, each with its own scheduling overhead. A better native API accepts the whole collection and lets native code do the iteration:
await NativeModule.saveItems(items);
The difference in shape is easy to state:
❌ 500 boundary crossings × small operation
✅ 1 boundary crossing × 500 native operations
Batching is among the simplest and most effective architectural optimizations available. One design detail to plan for: a batch call needs a clear error contract, such as reporting which items failed, so that one bad item does not silently discard the rest.
Throttle or coalesce high-frequency events
Suppose a native module emits 120 updates per second. JavaScript rarely needs all of them. For many interfaces, around 20 meaningful updates per second look practically identical on screen. Instead of handling
120 events / second
you might process
20 events / second
or accumulate events on the native side and forward only the latest state. Doing the throttling natively is usually better than doing it in JavaScript, because events dropped in JavaScript have already paid the crossing cost. This approach suits:
- location tracking,
- scroll events,
- sensor data,
- progress reporting,
- audio levels,
- network statistics,
- map movement,
- live dashboards.
The cheapest event is the one that is never sent.
Move meaning, not raw data
Rather than shipping something like
10 MB raw dataset
from native to JavaScript and asking JavaScript to derive an answer, compute the answer where the data already lives. Replace this flow:
Native
↓
10 MB data
↓
JavaScript
↓
find average
with this one:
Native
↓
calculate average
↓
JavaScript receives: 42.7
In short, transfer
meaning
instead of
raw information
whenever the architecture allows it. The trade-off is that logic moves into native code, which must be written and tested per platform or in shared C++; reserve this for data that is genuinely large or frequent.
Network latency is not interaction latency
A fast backend can hide a slow experience. Say the API responds in
120 ms
and the monitoring dashboard is green. After the response lands, though, a lot still has to happen:
API Response
↓
JSON Parsing
↓
State Transformation
↓
Redux / Zustand / Context
↓
Component Re-rendering
↓
JS ↔ Native interaction
↓
Layout
↓
UI Commit
By the time the result is on screen, the user may have waited
200+ ms
Backend monitoring reports that everything is fast; the user reports that the app is slow. Both are telling the truth, because they are measuring different things. Backend latency and interaction latency are separate metrics, and for mobile apps the second one is usually what decides whether the product feels good.
Test on the devices your users actually own
Performance problems love to hide on developer hardware. A flagship phone has a fast CPU, plenty of memory, quick storage, a strong GPU and good thermal headroom, so everything looks fine. Production users then run the same build on phones with far fewer resources, and
small inefficiency × thousands of operations
suddenly becomes visible. Testing only on premium devices is risky. A sound device matrix covers at least:
High-end device
Mid-range device
Lower-end supported device
This matters most when your audience includes markets where affordable Android phones make up a large share of users.
Measure release builds, not development builds
Development builds do extra work for warnings, debugging and tooling, and the official performance guide explicitly recommends measuring with release builds. Before concluding that React Native itself is slow, profile a
Release / Production build
rather than relying only on a
Development build
Otherwise you can spend hours fixing a slowdown that no user would ever encounter.
A diagnostic checklist for a slow screen
Sprinkling useMemo() across a sluggish screen is rarely the right first move. Work through these questions instead.
1. Is the JavaScript thread busy?
Look for:
- expensive loops,
- synchronous parsing of large data,
- heavy state transformations,
- avoidable re-renders,
- excessive logging,
- very large component trees.
2. Is the UI thread busy?
Look for:
- costly native rendering,
- complicated layouts,
- oversized images,
- deep view hierarchies,
- animations running on the wrong thread.
3. Is the native boundary crossed too often?
Search for round trips of this shape, particularly high-frequency ones:
Native → JS → Native
4. How big are the payloads?
Counting calls is not enough. Measure how much data flows through each one.
5. Can calls be batched?
Where possible, turn
many small operations
into
one meaningful operation
6. Does JavaScript need this data at all?
If native produces 1,000 data points and JavaScript only uses their average, send the average.
7. Does it need to happen right now?
Keep
User-critical work
apart from
background work
Analytics should never compete with touch feedback for the same frame.
8. Is a dependency still on the legacy architecture?
The current React Native docs mark Legacy Native Modules and Legacy Native Components as deprecated and recommend Turbo Native Modules and Fabric Native Components where appropriate. When you pick a package for a performance-sensitive feature, check which architecture it supports; a library running through the interop layer may not deliver the gains you expect. Support status changes quickly, so verify it against the library's current release notes.
A rule for placing work
For any performance-sensitive feature, start by deciding which side of the app is actually responsible for the work.
- When the job belongs to JavaScript (business rules, UI state, orchestration), let it live there and avoid reaching into native for every step.
- When the job belongs to native code (sensor loops, frame processing, audio buffers), let those hot paths run natively and stay there.
- Cross the boundary when there is something meaningful to say, not so that every intermediate step is visible on both sides.
A healthy split tends to look like this:
┌───────────────────────┐
│ JavaScript │
│ │
│ Business decisions │
│ Application state │
│ UI orchestration │
└───────────┬───────────┘
│
Meaningful calls
│
┌───────────▼───────────┐
│ Native │
│ │
│ Platform APIs │
│ High-frequency work │
│ Hardware interaction │
└───────────────────────┘
and not like this, hundreds of times per second:
JS ↔ Native ↔ JS ↔ Native ↔ JS ↔ Native
What the New Architecture actually fixes
The New Architecture removes several structural limits of the old model. Instead of building everything around a mandatory, serialized, asynchronous Bridge, modern React Native provides direct interfacing through JSI:
JSI
↓
Direct JS/native interfacing
alongside the other pieces that sit on top of it:
TurboModules
↓
Modern native module system Fabric
↓
Modern rendering architecture Codegen
↓
Typed interfaces between environments Modern scheduling
↓
Better prioritization of work
The React Native team describes removing the Bridge as reducing serialization overhead, improving startup and enabling more direct communication between JavaScript and the native runtime. The deeper change is flexibility. The framework now has more control over:
- when work runs,
- where work runs,
- how native functionality is reached,
- which updates get priority.
The lesson applies to every boundary
Step back from React Native and the pattern is general: every architectural boundary has a price. Think of these crossings:
Browser → API
Service → Service
Application → Database
Process → Worker
JavaScript → Native
Application → Redis
Backend → Message Broker
Systems are often designed as if calls across these lines behave like ordinary in-process function calls. They do not. Each boundary adds some mix of:
- serialization,
- copying,
- scheduling,
- queueing,
- networking,
- synchronization,
- context switching,
- error handling,
- memory management.
The Bridge just made that principle unusually visible inside a phone app.
Fix architecture before micro-optimizations
It is tempting to reach first for tools like these:
useMemo(...)
useCallback(...)
React.memo(...)
They have real uses. But if the app is moving 5 MB of unneeded data between runtimes 30 times per second, shaving 0.2 ms with useMemo() is not going to matter. Performance work is most effective when it proceeds in this order:
Architecture
↓
Data flow
↓
Scheduling
↓
Rendering
↓
Algorithms
↓
Micro-optimizations
and not the other way around.
Key takeaways
The legacy Bridge was not simply bad engineering. It let JavaScript and native code stay decoupled while communicating asynchronously, and it carried React Native a long way. As apps took on real-time messaging, rich animation, camera and machine-learning pipelines, maps, audio processing, complex gestures and other high-frequency native interaction, the limits of serialized asynchronous messaging became easy to hit. JSI, TurboModules, Fabric and the redesigned scheduling and rendering model remove many of those limits, but not the underlying economics.
- Measure the whole interaction pipeline, not just the native call or the API response.
- Budget against the frame: about 16.67 ms at 60 Hz and about 8.33 ms at 120 Hz.
- Batch operations, throttle or coalesce event streams at the source, and send results instead of raw data.
- Decide which runtime owns each responsibility and keep high-frequency loops on one side.
- Profile release builds on mid-range and low-end devices, and check whether dependencies still rely on legacy modules.
- Treat JSI as a faster transport, not a license to make more calls.
When an app feels slow even though every component measures fast, look at the gaps between those components. Often the expensive part is not the work itself but the distance the data travels to reach it.