The Browser Rendering Pipeline

Understand the stages a browser goes through to turn HTML, CSS, and script into pixels on screen, and connect that pipeline to how a React re-render eventually becomes something the user sees.

Every time you call setState in React, you are not directly painting anything on screen — you are kicking off a chain of work that ends, several steps later, in the browser's own rendering pipeline redrawing part of the page. Understanding that pipeline — HTML → DOM → CSSOM → Render Tree → Layout → Paint → Compositing — explains why some UI updates are essentially free and others are expensive, why changing an element's transform is cheaper than changing its width, and what React's virtual DOM diffing is actually trying to minimize. This lesson walks through each stage of the browser pipeline in order, then explicitly maps React's own render cycle onto it, so that 'React re-renders' and 'the browser repaints the screen' stop being synonyms in your head and become two connected but distinct processes.

From bytes to a page: HTML, DOM, and CSSOM

The pipeline begins when the browser receives raw HTML bytes over the network. The HTML parser tokenizes and parses this into the Document Object Model (DOM) — a tree of node objects representing every element, text node, and comment in the document, with parent/child/sibling relationships mirroring the nesting of the markup. This DOM tree is what JavaScript APIs like document.querySelector and element.appendChild read and manipulate, and it is also the tree React ultimately updates when it commits changes.

In parallel, the browser parses every CSS source — external stylesheets, <style> blocks, and inline style attributes — into the CSS Object Model (CSSOM), a tree of style rules with computed specificity and cascade resolved. The CSSOM on its own doesn't know which elements exist in the document; it just describes selectors and the declarations that apply through them. The browser must combine CSSOM with the DOM to know what actually applies to what.

CSS is render-blocking: because any subsequent element's final appearance can depend on a stylesheet that hasn't finished loading yet, the browser will not proceed past CSSOM construction (for the purposes of building the render tree) until all discovered stylesheets have been fetched and parsed — this is one of the practical reasons to keep critical CSS small and to load non-critical stylesheets asynchronously.

Render Tree, Layout, and Paint

The Render Tree (sometimes called the 'frame tree') is built by walking the DOM and, for each visible node, attaching the computed styles from the CSSOM. Nodes that are not visually rendered — elements with display: none, <head>, <script> — are excluded from the render tree entirely (note: visibility: hidden elements *are* included, since they still occupy layout space; only display: none removes an element from layout altogether). The render tree therefore represents exactly what needs to be measured and drawn.

Layout (also called reflow) is the stage where the browser computes the exact size and position, in pixels, of every node in the render tree, given the viewport size and the CSS box model rules (margin, border, padding, content sizing, flexbox/grid resolution, etc.). Layout is inherently a whole-subtree, often whole-document, computation: changing the width of one element can ripple outward and change the position of many other elements, which is why layout is one of the more expensive stages, and why triggering it repeatedly (e.g., reading offsetWidth in a loop right after writing styles — 'layout thrashing') is a classic performance bug.

Paint is the stage where the browser fills in actual pixels for each element according to its computed geometry and visual styles — text, colors, borders, shadows, images — typically recorded as a series of draw calls organized into one or more layers rather than immediately rasterized to the screen buffer.

Compositing

Compositing is the final stage, where the browser's compositor thread takes the painted layers and combines them into the final image shown on screen, applying any transforms, opacity changes, or scroll offsets. Certain CSS properties — most notably transform and opacity — can be handled entirely by the compositor on layers that are already painted, without re-running layout or paint at all. This is why animating transform: translateX(...) is dramatically cheaper and smoother than animating left or margin-left: the former only touches compositing, while the latter forces layout (geometry changed) and repaint (pixels changed) on every frame, in addition to compositing.

This gives a rough cost ordering worth memorizing: changing layout-affecting properties (width, height, top/left with static/relative positioning, font-size, adding/removing DOM nodes) is most expensive because it forces layout → paint → composite; changing paint-only properties (color, background-color, box-shadow, visibility) skips layout but still forces paint → composite; changing compositor-only properties (transform, opacity, filter under certain conditions) can skip both layout and paint, running only on the compositor thread, which is why they are the preferred properties for smooth 60fps animations.

• HTML → DOM: raw markup parsed into a tree of node objects. • CSS → CSSOM: stylesheets parsed into a tree of cascaded, specificity-resolved rules. • DOM + CSSOM → Render Tree: only visible nodes, each paired with its computed style. • Render Tree → Layout (reflow): exact size and position of every node, in pixels. • Layout → Paint: pixels are drawn for text, colors, borders, images, per layer. • Paint → Compositing: layers are combined, transforms/opacity applied, final frame shown.

Where React fits into this pipeline

React does not have its own rendering pipeline that bypasses the browser's — it produces DOM mutations that feed into the exact pipeline described above. The React-specific cycle is: state or props change → React re-renders the affected component(s) by calling their function bodies to produce new React elements → React's reconciler diffs the new element tree against the previous one (using the virtual DOM as an intermediate, in-memory representation) → React computes the minimal set of actual DOM mutations needed → React commits those mutations to the real DOM in one synchronous batch.

It is only after that commit — after React has actually called methods like appendChild, setAttribute, or assigned to element.style — that the browser's own pipeline takes over: the mutated portions of the DOM (and, if any styles changed, the CSSOM) feed into a fresh Render Tree computation, which triggers Layout for anything whose geometry could have changed, Paint for anything whose pixels changed, and Compositing to produce the final frame. React re-rendering a component in memory is cheap and does not, by itself, cause any browser layout or paint — the cost only appears once React commits an actual DOM change, and even then only for the parts of the tree that actually changed geometry or appearance.

This is the core value proposition of React's diffing: without it, naively re-creating the entire DOM subtree on every state change would force the browser to redo Layout and Paint for far more of the page than actually changed. By committing only the minimal DOM mutations, React keeps the browser's own expensive stages (Layout, Paint) scoped to the smallest possible area — which is also why the advice from the compositing discussion above still applies inside React: animating a component's transform/opacity via state changes is cheap even though it round-trips through React's render cycle, while animating its width or top via state is not.

Code Examples

Both components render a sliding panel in response to a prop change, and both go through the same React render/commit cycle. The difference is entirely in what the browser's pipeline has to do afterward: animating left changes the element's computed geometry every frame, forcing Layout and Paint on every frame of the transition; animating transform: translateX(...) changes only how an already-painted layer is composited, so the browser can skip Layout and Paint for every frame after the first and run the animation purely on the compositor thread. This distinction exists entirely at the browser-pipeline level; React has no special knowledge of which CSS properties are 'cheap' — it just commits whatever style value you gave it, and the browser's Layout/Paint/Compositing stages take it from there.

transform: open ? "translateX(0)": "translateX(-300px)" Clicking the button triggers setCount, which schedules a re-render. React calls the Counter function again (pure in-memory work — the console.log fires with no browser pipeline cost), diffs the resulting element tree against the previous one, determines that only the text inside the <p> changed, and commits a single targeted DOM mutation. Only after that mutation lands does the browser's own pipeline run for the affected node, not the whole page. Expected result: render (logged once per click, before any repaint occurs)

function ExpensiveSlide({ open }: { open: boolean }) {
  // Forces Layout (geometry changed) + Paint + Compositing on every change
  return (
    <div
      style={{
        position: "absolute",
        left: open ? 0 : -300,
        width: 300,
        transition: "left 300ms ease",
      }}
    >
      Panel content
    </div>
  );
}

function CheapSlide({ open }: { open: boolean }) {
  // Compositor-only: skips Layout and Paint entirely
  return (
    <div
      style={{
        position: "absolute",
        width: 300,
        transform: open ? "translateX(0)" : "translateX(-300px)",
        transition: "transform 300ms ease",
      }}
    >
      Panel content
    </div>
  );
}
function Counter() {
  const [count, setCount] = useState(0);
  console.log("render"); // runs on every re-render, purely in memory

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}

// Conceptually, after clicking the button, React's commit phase does
// something equivalent to this direct DOM call:
//   pElement.firstChild.textContent = String(nextCount);
// That single text-content mutation is what the browser's pipeline
// then picks up: Render Tree recompute for that text node → Layout
// (text width may have changed) → Paint (new digits drawn) → Composite.

Browser rendering pipeline, driven by a React update

The browser's own pipeline (top) turns markup and styles into pixels in six stages. React's render/commit cycle (bottom) does not replace this pipeline — it produces the DOM mutations that become its input, and React's diffing exists specifically to keep those mutations, and therefore the Layout/Paint work they trigger, as small as possible.

Network                                Browser rendering pipeline
 ┌────────┐    ┌─────┐    ┌───────┐    ┌─────────────┐    ┌────────┐    ┌───────┐    ┌─────────────┐
 │  HTML  │ →  │ DOM │    │  CSS  │ →  │    CSSOM    │    │        │    │       │    │             │
 └────────┘    └──┬──┘    └───────┘    └──────┬──────┘    │        │    │       │    │             │
                   │                           │           │             │       │    │             │
                   └─────────────┬─────────────┘           │             │       │    │             │
                                  ▼                         │             │       │    │             │
                          ┌───────────────┐                 │             │       │    │             │
                          │  Render Tree   │ ───────────────┘             │       │    │             │
                          └───────┬───────┘                               │       │    │             │
                                  ▼                                       │       │    │             │
                            ┌──────────┐                                  │       │    │             │
         
…

Important Rules

The browser pipeline runs in this order: HTML → DOM, CSS → CSSOM, DOM+CSSOM → Render Tree, Render Tree → Layout, Layout → Paint, Paint → Compositing.

display: none removes an element from the Render Tree entirely; visibility: hidden keeps it in the Render Tree (and in Layout) but skips Paint for it.

Changing geometry-affecting CSS (width, height, top/left, font-size, margin, adding/removing nodes) forces Layout, then Paint, then Compositing.

Changing visual-only CSS (color, background-color, box-shadow) skips Layout but still forces Paint and Compositing.

Changing transform, opacity, or filter can often be handled by Compositing alone, skipping Layout and Paint — prefer these for animation.

React's render phase (calling component functions, diffing) happens entirely in memory and has no browser-pipeline cost by itself; the cost begins only when React commits actual DOM mutations.

React's diffing/reconciliation exists to minimize the size and scope of committed DOM mutations, which in turn minimizes how much Layout and Paint work the browser's pipeline has to redo.

Why React DevTools' Profiler separates 'render' from 'commit'

React DevTools' Profiler explicitly separates the time spent rendering (calling component functions and diffing, in memory) from the time spent committing (applying DOM mutations). This distinction exists precisely because of the pipeline described in this lesson: render time reflects React's own JavaScript work and is invisible to the browser's rendering pipeline, while commit time is where DOM mutations happen that then trigger Layout, Paint, and Compositing outside of React's control. A component that renders slowly but commits few DOM changes is a very different performance problem than one that renders fast but commits large geometry changes — the profiler lets you tell them apart, and the fix for each is different: memoization and fewer re-renders for the former, CSS/animation choices and smaller DOM diffs for the latter.

Animating an element's width, left, or top with a React state value on every animation frame.

Animate transform (translate/scale) and opacity instead, keeping width/left/top static.

Geometry-affecting properties force Layout and Paint on every frame of the animation; transform/opacity can run on the compositor alone, producing smoother animation with far less browser work per frame, even though both go through the same React render/commit cycle.

Reading element.offsetWidth immediately after writing several style changes, in a loop, inside a React effect.

Batch all style/DOM writes together, then read layout-dependent values once afterward (or avoid synchronous layout reads in a loop).

Reading a layout-dependent property forces the browser to synchronously flush any pending Layout work; doing this repeatedly in a loop (write, read, write, read...) is 'layout thrashing' and forces Layout to re-run far more often than necessary, independent of anything React itself is doing.

What is the correct order of the browser's core rendering pipeline stages? A. DOM → Paint → CSSOM → Layout → Render Tree → Compositing B. HTML → DOM, CSS → CSSOM, then Render Tree → Layout → Paint → Compositing C. Render Tree → HTML → DOM → CSSOM → Compositing → Paint D. Layout → Render Tree → Paint → DOM → Compositing

B. HTML → DOM, CSS → CSSOM, then Render Tree → Layout → Paint → Compositing — HTML is parsed into the DOM and CSS into the CSSOM; DOM+CSSOM combine into a Render Tree of visible nodes with computed styles, which then goes through Layout (geometry), Paint (pixels), and Compositing (final frame assembly).

Why is animating transform generally cheaper than animating left/top for the same visual motion? A. transform is a newer CSS property so browsers optimize it automatically for all cases B. transform can often be handled entirely by Compositing, skipping Layout and Paint, while left/top force Layout then Paint on every frame C. left/top are not supported in modern browsers D. There is no real difference; it is a common misconception

B. transform can often be handled entirely by Compositing, skipping Layout and Paint, while left/top force Layout then Paint on every frame — transform/opacity changes can be applied on already-painted compositor layers without recomputing geometry or repainting pixels, while changing left/top alters an element's computed geometry, forcing Layout and Paint every frame.

In React's cycle, at what point does the browser's rendering pipeline (Render Tree/Layout/Paint/Compositing) actually get triggered? A. As soon as a component function is called during React's render phase, before any diffing happens B. Only after React commits actual DOM mutations resulting from the diff — the in-memory render/diff phase itself has no pipeline cost C. Continuously, once per millisecond, regardless of whether state changed D. Only when the page is first loaded, never again after that

B. Only after React commits actual DOM mutations resulting from the diff — the in-memory render/diff phase itself has no pipeline cost — React's render (calling component functions) and reconciliation (diffing) happen entirely in memory. The browser's pipeline only runs once React commits real DOM mutations, and only for the parts of the DOM/CSSOM actually changed by that commit.

What is the effect of display: none versus visibility: hidden on the Render Tree? A. Both remove the element from the Render Tree entirely B. Both keep the element in the Render Tree with identical costs C. display: none removes the element from the Render Tree (no Layout space); visibility: hidden keeps it in the Render Tree and Layout, but it is not painted D. display: none only affects Paint, never Layout

C. display: none removes the element from the Render Tree (no Layout space); visibility: hidden keeps it in the Render Tree and Layout, but it is not painted — display: none excludes the element from the Render Tree entirely, so it takes no layout space; visibility: hidden keeps the element in the Render Tree and participates in Layout (reserving space) but is skipped during Paint.

A teammate wrote a tooltip component that animates its position by updating top and left in inline styles from a React state value on every mousemove event, and it feels janky/slow. Explain, using pipeline-stage names, why this is slow, and rewrite the approach so it is smooth.

Updating top/left on every mousemove causes, on every frame, a fresh Layout pass (the element's geometry changed) followed by Paint and Compositing — for a value changing dozens of times per second, this repeatedly runs the most expensive stages of the pipeline. The fix is to keep the element's base position fixed via top/left set once, and animate its offset using transform: translate(dx, dy) driven by the same mousemove state; because transform is compositor-only, the browser can skip Layout and Paint on every subsequent frame and only run Compositing, which is dramatically cheaper and smoother.

Think about which pipeline stages top/left force versus which stage transform can be handled by alone.