Skip to content

React Rendering Pipeline vs Browser Rendering Pipeline

advanced20 min read

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.

Mental Model

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);
  }
}
Quiz
During which phase of React's pipeline does actual DOM manipulation occur?

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
Quiz
React commits a change that sets element.style.transform = 'translateX(100px)'. Which browser pipeline steps run?

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:

  1. DOM measurements — Read offsetWidth, getBoundingClientRect() after React's mutations but before paint
  2. Synchronous DOM corrections — Adjust DOM based on measurements without the user seeing the intermediate state
  3. Scroll restoration — Set scrollTop before 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:

  1. Data fetching — no need to block paint
  2. Subscriptions — event listeners, WebSocket connections
  3. Analytics/logging — fire-and-forget side effects
  4. TimerssetTimeout, setInterval
Quiz
You need to measure a DOM element's height after React inserts it and adjust its parent's height accordingly — without the user seeing a flicker. Which hook do you use?

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:

Execution Trace
setState()
State update is enqueued on the fiber
Does not immediately trigger rendering
Batch
React batches all state updates from this event handler
Multiple setStates in one handler = one render
Schedule
React schedules a render via Scheduler (MessageChannel)
Lane assigned based on update context
Render Phase
React calls components top-down, producing new JSX
Pure, interruptible in concurrent mode
Reconciliation
Diff old vs new fiber tree, tag effects
Part of render phase — beginWork/completeWork
Commit: Before Mutation
getSnapshotBeforeUpdate (class components)
Read DOM before it changes
Commit: Mutation
Apply all DOM changes (insert, update, delete)
Synchronous, uninterruptible
Commit: Layout
useLayoutEffect callbacks, componentDidMount/Update
DOM is mutated but browser hasn't painted
Browser: Style
Recalculate computed styles for affected elements
Cascade, specificity, inheritance
Browser: Layout
Compute geometry (position, size) for render tree
Most expensive stage for width/height changes
Browser: Paint
Generate drawing commands per layer
Skipped for compositor-only changes
Browser: Composite
Assemble layers, apply transforms, send to GPU
Final step — pixels appear on screen
useEffect
Effect callbacks fire asynchronously
After paint — user has already seen the update

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.

Quiz
React's concurrent render phase takes 30ms total. How many frames does it span at 60fps?

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, offsetHeight
  • scrollTop, scrollLeft, scrollWidth, scrollHeight
  • clientTop, clientLeft, clientWidth, clientHeight
  • getComputedStyle() (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

ScenarioHookWhy
Fetch data on mountuseEffectNo need to block paint — show loading state immediately
Measure DOM and adjust layoutuseLayoutEffectMust read/write DOM before paint to avoid flicker
Subscribe to external storeuseEffectSubscription setup doesn't need to block paint
Synchronize scroll positionuseLayoutEffectScroll jumps are visible if you wait until after paint
Analytics eventuseEffectFire and forget — never block rendering
Tooltip positioninguseLayoutEffectCalculate position before paint to avoid position jump
Animation state syncuseLayoutEffectInitialize animation state before first frame
Quiz
A component uses useEffect to set scrollTop after rendering a long list. The user briefly sees the list scrolled to the top before it jumps to the correct position. What's the fix?
Key Rules
  1. 1React operates within the browser's JavaScript phase. After React commits DOM changes, the browser runs Style → Layout → Paint → Composite.
  2. 2useLayoutEffect fires after DOM mutation, before browser paint. Use for DOM measurement and synchronous corrections.
  3. 3useEffect fires after browser paint. Use for data fetching, subscriptions, analytics — anything that doesn't need to block paint.
  4. 4The commit phase is synchronous. All DOM mutations apply in one pass, then the browser processes them in its pipeline.
  5. 5In concurrent mode, the render phase spans multiple browser frames (yielding every 5ms). The commit phase is always within one frame.
  6. 6Layout thrashing occurs when DOM reads and writes are interleaved in effects. Batch reads first, then writes.
  7. 7CSS transform and opacity changes skip Layout and Paint — they run on the compositor thread for maximum performance.
Interview Question

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.