React Native Rendering, Part 2: The Bridge and Three Threads — Why Layout Was Asynchronous
In Part 1 we established why React Native had to build its own rendering pipeline: there is no DOM, no CSS engine, and no browser where the browser used to be. React Native had to provide its own host tree, its own layout engine, and its own way of getting pixels onto the screen.
This article is about the first version of that pipeline — the architecture React Native used for years, known today as the legacy architecture (or Paper). Almost every "weird" React Native behavior you've heard of comes from here:
- "Why can't I read a view's size synchronously?"
- "Why does my tooltip flash in the wrong place for one frame?"
- "Why does my JS-driven animation stutter while the JS thread is busy?"
All three answers are the same, and that answer is: three threads talking over an asynchronous Bridge.
What You'll Learn
- The three threads of the legacy architecture, and what each one owns
- What the Bridge actually is, and why "asynchronous + serialized" is the source of so much behavior
- The end-to-end render flow of the old architecture: JS → shadow tree → Yoga → UI
- Why layout was asynchronous, and the real bugs that follow from it
- Why the old architecture hit a wall — and why React 18's concurrent features finally broke it
Prerequisites
- You've read why React Native couldn't reuse the browser:
- It helps to recall what threads are:
- And how the browser schedules work on its single main thread:
Starting from a Scenario
You're building a tooltip. It should sit right above a button. You measure the button, then render the tooltip at the right spot:
function Tooltip({ target }) {
const [y, setY] = useState(0);
return (
<View style={styles.wrapper}>
<View
style={styles.button}
onLayout={(e) => setY(e.nativeEvent.layout.y)}
/>
<View style={[styles.tooltip, { top: y - 40 }]} />
</View>
);
}You run it. For one frame, the tooltip shows up in the wrong place — top: -40 — and then, a moment later, it snaps to the correct position. It looks like a glitch.
Why can't React Native just measure the button and place the tooltip correctly before the first paint? Because layout is asynchronous — and in the legacy architecture, it has to be. To see why, we need to meet the three threads.
Core Content
1. Three threads, three jobs
The browser runs its whole pipeline on essentially one thread: parse, style, layout, paint, then your JavaScript. If your JS is busy, the page freezes. React Native deliberately splits that work apart:
┌───────────────────────────┐
│ JS thread │ React reconciler + your code
│ (JS engine, e.g. JSC) │ business logic, state, the React element tree
└─────────────┬─────────────┘
│ Bridge (async, JSON-serialized, batched)
▼
┌───────────────────────────┐
│ Shadow thread │ builds the shadow tree, runs Yoga layout
│ (background thread) │ turns styles into x / y / width / height
└─────────────┬─────────────┘
│ Bridge (async)
▼
┌───────────────────────────┐
│ UI / Main thread │ creates & updates native views
│ (platform thread) │ handles touch, draws pixels
└───────────────────────────┘- JS thread — runs your JavaScript: components, hooks, reconciliation. This is where the React element tree lives and where
setStatehappens. If you block this thread, your app's logic freezes — but notice, the UI thread is separate, so native scrolling can still keep going for a while. - Shadow thread — a background thread whose job is layout. It maintains a native-side "shadow tree" (think: a lightweight, layout-only copy of your UI) and runs Yoga on it to compute every node's position and size.
- UI / Main thread — the platform's own UI thread. It creates and updates the real
UIViews / AndroidViews, and it handles touch and drawing. This is the thread that must stay smooth.
Already you can see the difference from the browser: the place where you write code (JS thread) is not the place where pixels change (UI thread). Something has to carry information between them.
2. The Bridge: a mailbox, not a phone call
That "something" is the Bridge — and its single most important property is that it is asynchronous.
Here's what happens when JavaScript wants the native side to do something (say, update a view):
JS: call someNativeModule.doThing(arg1, arg2)
↓
serialize (arg1, arg2) into a message
↓
enqueue it onto the Bridge ─────────► returns IMMEDIATELY
(no result yet!)
↓
native: dequeue, deserialize, execute
↓
◄───────── result message travels back the same wayThree consequences fall out of this design, and they explain a huge fraction of React Native's reputation:
-
You can't get a synchronous return value. When JS calls across the Bridge, the call returns right away without the result. The value arrives later, on another turn.
-
Everything crossing the Bridge is serialized. Arguments and results are turned into a message format and parsed on the other side. Small and rare: fine. Large and frequent: expensive. The Bridge is a bottleneck by construction.
-
Messages are batched. The Bridge doesn't wake up the other side for every single call; it flushes a batch. Batching is good for throughput, but it adds latency ("this update will be handled soon, not right now").
Analogy: the Bridge is like mailing letters between two offices, not calling them on the phone. You drop the letter in the outbox and move on; the reply arrives by return mail, whenever it arrives. Efficient for a steady stream of letters; terrible when you need an answer right now.
A nice side effect of this: because the JS thread only drops messages and moves on, a slow JS thread does not directly block the UI thread. The UI thread keeps drawing and handling touches while JS is catching up. This is why a busy JS thread makes an app feel laggy rather than frozen. It was a deliberate trade: responsiveness over immediacy.
3. The old render flow, end to end
Now we can trace one update through the whole legacy pipeline. Suppose state changes on the JS thread.
Step 1 — React reconciles (JS thread).
React runs your render, diffs the new element tree against the old one, and computes the minimal set of host changes: "create a View," "update this View's backgroundColor," "insert these children." These become a batch of UIManager commands.
Step 2 — Cross the Bridge (JS → shadow thread). The batch is serialized and sent over the Bridge. It does not wait for a result.
Step 3 — Build the shadow tree and run layout (shadow thread). The shadow thread applies the commands to its shadow tree, then runs Yoga, which computes each node's final frame (x, y, width, height) from the Flexbox styles.
Step 4 — Cross the Bridge again (shadow thread → UI thread). The computed layout, plus the view mutations, cross the Bridge a second time.
Step 5 — Mount (UI thread). The UI thread creates and updates the actual native views, and the frame is drawn.
Notice the shape of it:
JS thread : setState → reconcile → enqueue commands ──┐
(Bridge)
shadow thread : build tree → Yoga layout → enqueue frames ─┐
(Bridge)
UI thread : apply to native views → draw ────────────┘Two Bridge crossings, none of them synchronous. The update "travels" instead of happening in place.
4. Why layout is asynchronous — and the bugs it causes
Now the tooltip makes sense. When you call onLayout, the callback fires after layout has been computed and mounted — which is already after at least one frame has been drawn. Your setState then triggers another pass through the entire pipeline. So the sequence is:
frame N : render tooltip at the default position (top: -40) ← wrong, but painted
frame N+... : onLayout fires → setState → another trip through the pipeline
frame N+k : tooltip finally renders at the correct positionThe result is the one-frame jump. It is not a bug in your code; it is the architecture showing through.
The same root cause produces the other famous symptoms:
- List flicker and blank frames. Fast updates can render intermediate states, because updates travel asynchronously and can arrive out of step with what the user sees.
- "Scroll feels janky." Scrolling is driven by the UI thread, but content and data come from the JS thread. If JS is busy, new items arrive late.
- JS-driven animations stutter. An animation that updates a value on every frame via
setStatehas to send a message across the Bridge on every frame. Under load, that can't keep up with the display's refresh rate. (This is exactly why theAnimatedAPI added a native driver option — it moves the animation to the native side so it never has to cross the Bridge per frame. We'll return to this in Part 7.)
5. Why anyone would design it this way
It's tempting to read all of this as "mistakes." It wasn't. The legacy architecture made a coherent bet for its time:
- Keep the UI thread safe. By making all JS↔native communication asynchronous, a slow or even crashed JS thread can't block touches and drawing. The app degrades gracefully instead of freezing.
- Batch aggressively. Grouping messages kept the Bridge from drowning in chatter during normal app usage.
- One copy of the UI. The native view hierarchy was the single source of truth, mutated in place. Simple, and it worked well for the common case.
For years, that bet paid off. The problems only became fatal when React itself changed its goals.
6. The breaking point: concurrent React
React 18 introduced concurrent rendering: the ability to start a render, interrupt it for something more urgent (like a user's keypress), and resume it later. Features like Suspense, Transitions, and priority-based updates all depend on it.
Look at what concurrent rendering demands, and compare it against the legacy architecture:
| What concurrent React needs | What the legacy architecture offered |
|---|---|
| Render on the UI thread when an update is urgent | The UI thread is off-limits to React |
| Interrupt and resume an in-progress render | One copy of the UI, mutated in place |
Read layout synchronously (e.g. in useLayoutEffect) | Layout is asynchronous, always |
| Keep multiple in-progress versions of the tree | A single native view hierarchy |
Almost every row is a direct conflict. The legacy architecture wasn't slow so much as structurally unable to support the React that was arriving. Fixing it piecemeal wasn't enough — React Native needed a new foundation for how JS and native talk, how the tree is represented, and how updates are scheduled.
That new foundation is the New Architecture, and its first brick is JSI.
Hands-On Practice
Basic Exercises
-
Watch the threads. Open React Native DevTools. Post a long-running loop on the JS thread (
while (Date.now() - start < 2000) {}) and try to scroll a list during it. Notice that the list may keep moving — proof that the UI thread is separate from the JS thread. -
Reproduce the layout jump. Build the tooltip from the scenario. Log the sequence of layout passes. You should see the tooltip render once at the default position before the corrected position arrives.
-
Measure the async gap. On mount, record a timestamp, then record another in
onLayout. The gap between them is the pipeline's round trip — the price of the asynchronous Bridge.
Advanced Reflection
An animation driven by setState on every frame stutters under load, but the same animation using Animated's native driver stays smooth. Using the render flow from Section 3, explain which steps the native driver removes — and which it can't.
Summary & Keywords
- The legacy architecture ran on three threads: JS (React + your code), shadow (Yoga layout), and UI (native views, touch, drawing).
- The Bridge connected them: asynchronous, serialized, batched. Two consequences dominate everything: no synchronous return values, and per-message cost.
- One update makes two Bridge crossings: JS → shadow tree/layout → UI thread. Layout is therefore asynchronous, which is the root cause of the one-frame layout jump, list flicker, and JS-driven animation stutter.
- It wasn't a mistake: the async Bridge was a deliberate bet on responsiveness over immediacy.
- It broke on concurrent React, which needs synchronous layout reads, interruptible renders, and multiple in-progress trees — none of which the legacy model can provide.
Keywords: Bridge, JS thread, shadow thread, UI thread, Yoga, asynchronous layout, serialization, batching, concurrent rendering
Further Reading and Next Article Preview
We've seen the problem: two worlds, joined by an expensive asynchronous mail service. The obvious question is: why send letters at all, when both worlds run on the same device? The next article introduces JSI — the interface that lets JavaScript hold a reference to a C++ object and call it directly, with no serialization and no mail.
Next up: React Native Rendering, Part 3: JSI — Removing the Bridge.
