React Native Rendering, Part 7: Concurrency and Animation — Making Updates Feel Instant
For six articles we have been building machinery: JSI, the Shadow Tree, Yoga, and the Fabric pipeline. This final part asks the only question a user ever actually feels: does it stay smooth?
The New Architecture makes smoothness possible in two different ways, and this article is about both:
- Concurrency — so that an urgent update (a tap, a keystroke) is never stuck waiting behind a big, low-priority one.
- Animation — so that the smooth, per-frame work of moving things doesn't run on the JS thread at all.
We will end by closing the loop back to where the series started: the stuttering, JS-driven animation from Part 2.
What You'll Learn
- How priorities let urgent updates interrupt less urgent ones — and why the immutable tree made that possible
- What "synchronous layout reads" mean for UI correctness, and the tooltip's happy ending
- Why a JS-driven animation stutters, and the two ways to move it off the JS thread
- What the native driver can and cannot animate, and why
- What Reanimated's "worklet" model buys you that the native driver cannot
- A mental checklist for keeping the JS thread out of the per-frame path
Prerequisites
- The Fabric pipeline — Render, Commit, Mount:
- The Shadow Tree, and why immutability enables multiple in-flight trees:
- Why
transformis smoother thanwidthin the browser — the same idea recurs here:
Starting from a Scenario
In Part 2, we watched an animation stutter. Same animation, two ways to write it:
// Version A: driven by JS state, one setState per frame
const [x, setX] = useState(0);
// ... on each frame: setX(next)
// Version B: Animated with the native driver
const x = useRef(new Animated.Value(0)).current;
Animated.timing(x, { toValue: 100, duration: 300, useNativeDriver: true }).start();Version A drops frames under load. Version B glides. The difference is a single boolean: useNativeDriver: true. Why does one flag change everything?
The answer is the theme of this article: which thread runs the per-frame work.
Core Content
1. Two ways to be "smooth"
"Smooth" means two different things, and the New Architecture attacks them separately.
- Correctness under contention: an urgent interaction should be handled immediately, even if the app is in the middle of a huge, non-urgent update. → Concurrency.
- Not doing heavy work per frame: moving something on screen should not require the JS thread (or even React) to wake up 60 times a second. → Animation offloading.
A truly smooth app needs both.
2. Concurrency, part 1: priorities
React 18 gave React the ability to treat updates with different priorities. In React Native, this shows up as the distinction between:
- Urgent updates — things the user is directly doing: typing in a field, pressing a button, dragging.
- Transition updates — things that can wait a moment: re-filtering a long list, rendering a heavy results view.
You opt into the second category with startTransition or by using useDeferredValue:
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// typing updates 'query' urgently; the expensive list re-renders
// with 'deferredQuery' at a lower priorityThe payoff: the input stays responsive even while a big list re-renders. If a newer keystroke arrives, React can interrupt the in-progress transition render and handle the keystroke first.
3. Concurrency, part 2: interruption, and why it needed an immutable tree
Interruption is the part that sounds impossible to anyone who lived through the legacy architecture. If there is only one copy of the UI, and it is being mutated in place, how can you abandon a half-finished render?
You cannot — which is exactly why the legacy architecture could not: Suspense, Transitions, and priority-based updates were all off the table. The New Architecture makes it possible because of Part 4's design:
- The in-progress render builds a new revision of the Shadow Tree; it never mutates the one currently on screen.
- When a more urgent update arrives, the renderer can simply drop the in-progress revision and start a higher-priority one — the drawn UI is unaffected throughout.
- When a low-priority render is resumed, it can pick up against the latest committed state.
This is also where discrete event interruption comes in. A tap or a keypress is a discrete event: its update must not wait. Fabric can handle such an event on the UI thread and render its update synchronously there, interrupting a background render for just that moment — then let the background work continue. Only the updates that belong to the discrete event are applied synchronously, keeping the UI-thread block as small as possible.
None of this is a small optimization. It is the difference between "the app hangs while the list re-renders" and "the app stays with my fingers."
4. Synchronous layout reads: the tooltip's happy ending
Back in Part 2, the tooltip flashed in the wrong place for one frame because layout was asynchronous: onLayout fired after the frame had already been painted, and positioning it correctly took another trip through the whole pipeline.
The New Architecture closes that gap. Layout now lives in C++ and is reachable through JSI, so a layout effect can read it and update in the same frame, before anything is shown:
useLayoutEffect(() => {
ref.current?.measure((x, y, w, h, pageX, pageY) => setY(pageY));
}, []);This is more than a nicety. Anything that depends on knowing real geometry before the user sees it — a tooltip, a popover, a measured animation start — was subtly broken in the old world and is correct now. Correctness, not just speed, is what synchronous layout buys.
5. Animation, part 1: why the JS thread stutters
Now the animation. Consider Version A from the scenario: a value updated by setState every frame.
Walk it through the Fabric pipeline (Part 6) and the problem is obvious. Every single frame, you need:
- A JS callback to run (the animation step),
- A React render + commit (build a new Shadow Tree revision),
- Yoga layout (if the animated property affects layout),
- A mount.
That is the entire pipeline — per frame — just to nudge something a few pixels. If the JS thread is busy with anything else (a network parse, a big render), step 1 is late, and the frame drops. Scroll a list and run a JS-driven animation at the same time, and they fight for the same thread.
The fundamental fix is not to optimize those steps. It is to not do them on the JS thread at all.
6. Animation, part 2: the native driver
The Animated API's native driver (useNativeDriver: true) does exactly that. Instead of computing the animation value in JavaScript each frame, it hands the animation to the native side, which updates the value there and applies it directly on the UI thread — no JS callback, no React render, no Yoga, no commit. The JS thread only starts the animation and steps out of the way.
But the native driver cannot animate everything, and the reason is the key insight of this whole article. It can animate:
transform(translate, scale, rotate), andopacity,
because changing these does not require re-running layout. The renderer can apply them at the very last stage — inside the already-computed frame. It cannot animate layout-affecting properties like width, height, top, or left, because each new value would require re-running Yoga — a layout pass per frame, by definition. This is the same reason the browser advice "animate transform and opacity, not width" holds; see the linked article on the rendering pipeline.
So the native driver is a declarative escape hatch for exactly the properties that do not need layout. It is powerful, and it is narrow.
7. Animation, part 3: Reanimated and the worklet runtime
What if you need to animate something the native driver cannot — a layout property, a gesture-following element, a complex interaction — and still stay off the JS thread?
Reanimated answers this with a fundamentally different idea: run the animation logic itself on the UI thread. Reanimated sets up a second JavaScript runtime, on the UI thread, and moves functions tagged as worklets into it. Using JSI (Part 3), a worklet running on the UI thread can read and write values that update the renderer directly — with no trip to the main JS thread.
const offset = useSharedValue(0);
const pan = Gesture.Pan().onChange((e) => {
'worklet';
// runs on the UI thread — the finger and the element move in the same frame
offset.value = e.translationX;
});The difference from the native driver is one of reach:
- Native driver: declarative, limited to non-layout properties, extremely simple.
- Reanimated: imperative worklets on the UI thread, able to animate anything — including layout properties and gesture-driven interactions — with the JS thread out of the loop.
That breadth is why Reanimated is the tool of choice for gesture-heavy, interactive UI. Its cost is complexity: you now reason about which runtime a function runs in.
8. The unifying principle
Step back, and everything in this article reduces to one sentence:
Keep the JS thread out of the per-frame path.
- Urgent updates do not wait behind slow ones → priorities.
- Geometry that must be known before paint is read synchronously → layout effects.
- Motion that happens every frame does not run in JS → native driver / Reanimated.
This is also why the series kept returning to the same theme. The legacy architecture forced per-frame work to march through JS, the Bridge, and back. The New Architecture's parts — JSI, the immutable Shadow Tree, Yoga, the Fabric pipeline — were, one by one, removing the reasons for the JS thread to stand between the user's finger and the screen.
9. Debugging: which thread is the bottleneck?
When something does stutter, the first diagnostic question is always the same: which thread is behind?
- JS thread busy? A heavy render, a big computation, an expensive effect, or a JS-driven animation. The fix is usually work offloading or
startTransition. - UI thread busy? An expensive mount, a large native view tree, or layout-thrashing. The fix is usually fewer native views (view flattening) or animating non-layout properties.
- Tooling — React Native DevTools and platform profilers — can show both. The mental model from this series is what lets you read the output and know where to look.
Hands-On Practice
Basic Exercises
-
Flip the flag. Take a slide-in animation and run it with
useNativeDriver: falseand thentrue. Force some JS-thread load (a busy loop in asetInterval) and watch the gap widen. -
Try an animatable property the driver cannot take. Attempt to drive
widthwith the native driver. React Native will warn you — because every frame would need a layout pass. -
Stay responsive. Build a long filtered list. Type in the input and watch it jank. Wrap the list update in
startTransition(or useuseDeferredValue) and watch the input stay smooth while the list catches up.
Advanced Reflection
Using Part 6's Render/Commit/Mount, name exactly which phases the native driver eliminates from the per-frame path — and then explain, using Yoga, why a layout-affecting property cannot be eliminated the same way.
Summary & Keywords
- Smoothness has two fronts: correctness under contention (concurrency) and not doing heavy per-frame work (animation offloading).
- Concurrency: priorities separate urgent from transition updates; the immutable Shadow Tree lets React interrupt and drop in-progress renders; discrete event interruption renders urgent updates synchronously on the UI thread.
- Synchronous layout reads (in a layout effect) fix geometry-before-paint bugs like the tooltip's one-frame jump — a matter of correctness, not just speed.
- The native driver moves per-frame animation to the UI thread, but only for non-layout properties (
transform,opacity) — because layout properties would force a Yoga pass per frame. - Reanimated runs animation logic in a worklet runtime on the UI thread, animating anything, including layout properties and gestures, with the JS thread out of the loop.
- The unifying rule: keep the JS thread out of the per-frame path.
Keywords: concurrent React, priority, startTransition, useDeferredValue, discrete event interruption, useLayoutEffect, synchronous layout, native driver, Reanimated, worklet, shared value, UI thread
Series Recap and Where to Go Next
That is the whole pipeline, end to end. Here is the journey in one place:
- Why RN can't reuse the browser — no DOM, no CSS engine; RN had to build its own pipeline.
- The Bridge and three threads — the legacy design, and why layout was asynchronous.
- JSI — removing the Bridge; direct JS ↔ C++ references.
- The Shadow Tree — an immutable C++ host tree, structurally shared.
- Yoga — Flexbox without a browser.
- The Fabric pipeline — Render, Commit, Mount across three threads.
- Concurrency and animation — keeping updates and motion off the JS thread.
If you want to go deeper from here:
- The browser series is the natural companion to this one; the two pipelines rhyme:
- The process/thread foundation underneath everything:
- And the legacy design we spent Part 2 on:
