React Rendering Pipeline vs Browser Rendering Pipeline
Two Pipelines, One Screen
Most React developers think of rendering as "React does its thing and stuff appears on screen." But there are actually two distinct pipelines between your setState call and the pixels on your monitor. React's pipeline produces DOM mutations. The browser's pipeline turns those mutations into pixels. Understanding where one ends and the other begins is essential for diagnosing performance issues, choosing the right hooks, and reasoning about what the user sees.
React's Pipeline: Browser's Pipeline:
───────────────── ────────────────────
1. Trigger (setState/props) 1. JavaScript (React's commit runs here)
2. Render (call components) 2. Style (recalculate computed styles)
3. Reconcile (diff old vs new) 3. Layout (compute geometry)
4. Commit (apply DOM mutations) 4. Paint (generate display lists)
│ 5. Composite (GPU layer assembly)
└──────→ hands off here ──────→
React operates entirely within the "JavaScript" phase of the browser's pipeline. When React commits (applies DOM mutations), the browser takes over — recalculating styles, computing layout, painting, and compositing.
React is a JavaScript program that writes instructions for the browser's rendering engine. React decides what changes in the DOM. The browser decides how those changes become pixels. React produces the script. The browser performs the play. Understanding both pipelines means understanding the full path from setState to pixels on screen.
React's Pipeline in Detail
Phase 1: Trigger
Something causes a render: setState, a parent re-render, a context change, or an external store update.
// These all trigger React's pipeline:
setState(newValue); // State update
<Child prop={newValue} /> // Parent re-render with new props
useContext(ThemeContext); // Context value changed
useSyncExternalStore(store.subscribe); // External store changed
Phase 2: Render
React calls component functions (or class render() methods), producing JSX. This is pure computation — no DOM access, no side effects. In concurrent mode, this phase is interruptible.
function Dashboard({ data }) {
// This entire function body runs during the RENDER phase
const filtered = data.filter(d => d.active);
const sorted = filtered.sort((a, b) => a.date - b.date);
return (
<div>
{sorted.map(item => (
<DashboardCard key={item.id} item={item} />
))}
</div>
);
}
Phase 3: Reconcile
React diffs the new JSX tree against the previous fiber tree, marking fibers with effect flags (Placement, Update, Deletion). This is part of the render phase internally — beginWork and completeWork perform both rendering and reconciliation.
Phase 4: Commit
React applies all DOM mutations synchronously. This is where React "hands off" to the browser. Every insertBefore, removeChild, setAttribute call happens here.
// What the commit phase does (simplified)
function commitMutations(fiber) {
if (fiber.flags & Placement) {
parentNode.insertBefore(fiber.stateNode, beforeNode);
}
if (fiber.flags & Update) {
updateDOMProperties(fiber.stateNode, fiber.memoizedProps);
}
if (fiber.flags & Deletion) {
parentNode.removeChild(fiber.stateNode);
}
}
The Browser Pipeline After React Commits
This is where most React tutorials stop, but the story is only half over. After React's commit phase mutates the DOM, the browser must process those changes through its own pipeline before pixels appear on screen.
Step 1: Style Recalculation
The browser recalculates computed styles for every element affected by the DOM mutations. If React added a new element with className="card", the browser resolves what styles .card maps to — cascading, inheriting, and computing every CSS property.
Step 2: Layout (Reflow)
The browser computes the geometry of every element — position, width, height, margins. This is the most expensive browser pipeline stage. Changes to width, height, padding, margin, top, left, font-size, or inserting/removing elements trigger layout.
Step 3: Paint
The browser generates drawing commands for each layer. Background colors, text, borders, shadows, images — all converted to an ordered list of GPU-ready operations.
Step 4: Composite
The compositor assembles painted layers, applies transforms and opacity, and sends the final image to the GPU for display.
React setState
↓
React Render Phase (call components, diff)
↓
React Commit Phase (DOM mutations)
↓
Browser Style Recalculation
↓
Browser Layout (Reflow)
↓
Browser Paint
↓
Browser Composite
↓
Pixels on screen ← user sees the change here
useLayoutEffect vs useEffect: Before vs After Paint
Here's what trips up even experienced React developers. The timing of React's effect hooks relative to the browser's paint is one of the most misunderstood aspects of the pipeline.
useLayoutEffect: Fires Before Paint
useLayoutEffect runs synchronously after the commit phase (DOM mutations) but before the browser paints. The browser has not rendered the frame yet — the user has not seen the DOM changes.
React Commit (DOM mutations)
↓
useLayoutEffect callbacks fire (synchronous, blocking)
↓
Browser Style → Layout → Paint → Composite
↓
User sees the result
This makes useLayoutEffect the right place for:
- DOM measurements — Read
offsetWidth,getBoundingClientRect()after React's mutations but before paint - Synchronous DOM corrections — Adjust DOM based on measurements without the user seeing the intermediate state
- Scroll restoration — Set
scrollTopbefore the browser paints to avoid visible jump
function Tooltip({ anchorRef, children }) {
const tooltipRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
// Measure the anchor element AFTER React inserted the tooltip DOM
// but BEFORE the browser paints
const anchorRect = anchorRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
setPosition({
top: anchorRect.bottom + 8,
left: anchorRect.left + (anchorRect.width - tooltipRect.width) / 2,
});
// This triggers a synchronous re-render with correct position
// The browser paints ONCE with the tooltip in the right place
// User never sees the tooltip at (0, 0)
}, [anchorRef]);
return (
<div ref={tooltipRef} style={{ position: 'fixed', top: position.top, left: position.left }}>
{children}
</div>
);
}
useEffect: Fires After Paint
useEffect runs asynchronously after the browser has painted. The user has already seen the DOM changes from the commit phase.
React Commit (DOM mutations)
↓
Browser Style → Layout → Paint → Composite
↓
User sees the result
↓
useEffect callbacks fire (asynchronous)
useEffect is correct for:
- Data fetching — no need to block paint
- Subscriptions — event listeners, WebSocket connections
- Analytics/logging — fire-and-forget side effects
- Timers —
setTimeout,setInterval
The Full Timeline of a React Update
Let's put it all together. Here is exactly what happens, step by step, when setState is called:
Concurrent Rendering and Browser Frames
Now things get really interesting. In concurrent mode, React's render phase interleaves with browser frames. The work loop yields every ~5ms, giving the browser a chance to process events and paint.
Frame 1 (16.7ms budget):
├─ React: process fibers for 5ms
├─ Browser: process input events (2ms)
├─ React: process more fibers for 5ms
├─ Browser: idle (4.7ms remaining)
└─ Browser: paint if needed
Frame 2:
├─ React: process remaining fibers for 3ms
├─ React: commit (1ms) — DOM mutations applied
├─ React: useLayoutEffect (0.5ms)
├─ Browser: style + layout + paint + composite (4ms)
└─ Browser: idle
Frame 3:
├─ React: useEffect callbacks (2ms)
├─ Browser: idle (14.7ms)
└─ Browser: paint if needed
The render phase spans multiple frames. The commit phase happens entirely within one frame (it is synchronous). The browser's pipeline runs after React's commit within the same frame — the user sees the update at the next paint.
Layout Thrashing: When React Meets Browser Layout
This is the performance pitfall that silently tanks your frame rate. React's commit phase can inadvertently cause layout thrashing — forced synchronous layout — if effects read layout properties immediately after mutations.
// LAYOUT THRASHING in useLayoutEffect
useLayoutEffect(() => {
// React just mutated the DOM (commit phase)
// Reading offsetHeight forces the browser to synchronously compute layout
const height = element.offsetHeight; // FORCED LAYOUT
// Writing a style invalidates the layout
element.style.height = `${height * 2}px`; // LAYOUT INVALIDATED
// Reading again forces ANOTHER synchronous layout calculation
const newHeight = element.offsetHeight; // SECOND FORCED LAYOUT
}, []);
Each read-after-write cycle forces the browser to recalculate layout synchronously. In a loop, this becomes O(n) forced layouts — and this is the primary cause of janky animations and scroll performance issues you've probably seen in the wild.
Fix: batch reads, then batch writes:
useLayoutEffect(() => {
// READ phase: gather all measurements
const height = element.offsetHeight;
const width = element.offsetWidth;
// WRITE phase: apply all mutations
element.style.height = `${height * 2}px`;
element.style.width = `${width + 20}px`;
// Only ONE layout calculation needed (when browser paints)
}, []);
Properties that trigger forced layout
Reading any of these properties after a DOM write forces synchronous layout:
offsetTop,offsetLeft,offsetWidth,offsetHeightscrollTop,scrollLeft,scrollWidth,scrollHeightclientTop,clientLeft,clientWidth,clientHeightgetComputedStyle()(for geometry-related properties)getBoundingClientRect()
The browser needs to compute layout to return accurate values. If the DOM was modified since the last layout, the browser must run a full layout pass synchronously. This is called "forced reflow" or "layout thrashing" when it happens repeatedly.
Performance-Aware Hook Selection
| Scenario | Hook | Why |
|---|---|---|
| Fetch data on mount | useEffect | No need to block paint — show loading state immediately |
| Measure DOM and adjust layout | useLayoutEffect | Must read/write DOM before paint to avoid flicker |
| Subscribe to external store | useEffect | Subscription setup doesn't need to block paint |
| Synchronize scroll position | useLayoutEffect | Scroll jumps are visible if you wait until after paint |
| Analytics event | useEffect | Fire and forget — never block rendering |
| Tooltip positioning | useLayoutEffect | Calculate position before paint to avoid position jump |
| Animation state sync | useLayoutEffect | Initialize animation state before first frame |
- 1React operates within the browser's JavaScript phase. After React commits DOM changes, the browser runs Style → Layout → Paint → Composite.
- 2useLayoutEffect fires after DOM mutation, before browser paint. Use for DOM measurement and synchronous corrections.
- 3useEffect fires after browser paint. Use for data fetching, subscriptions, analytics — anything that doesn't need to block paint.
- 4The commit phase is synchronous. All DOM mutations apply in one pass, then the browser processes them in its pipeline.
- 5In concurrent mode, the render phase spans multiple browser frames (yielding every 5ms). The commit phase is always within one frame.
- 6Layout thrashing occurs when DOM reads and writes are interleaved in effects. Batch reads first, then writes.
- 7CSS transform and opacity changes skip Layout and Paint — they run on the compositor thread for maximum performance.
Q: Trace the complete path from setState() to pixels on the user's screen, naming every phase in both React's and the browser's pipelines.
A strong answer follows this sequence: setState enqueues an update → React batches updates from the same event → Scheduler picks up the work (lane-based priority) → Render phase calls components and diffs fibers (interruptible in concurrent mode) → Commit phase applies DOM mutations synchronously (before mutation → mutation → layout sub-phases) → useLayoutEffect fires (DOM exists, no paint yet) → Browser runs style recalculation → layout (reflow) → paint (display list generation) → composite (GPU assembly) → pixels appear → useEffect fires asynchronously. Bonus: explain how concurrent rendering spreads the render phase across multiple frames via 5ms time slicing.