The Runtime Theory
Web Platform

The Browser Rendering Pipeline: From HTML to Pixels

How the browser actually renders a page: parse to DOM and CSSOM, style, layout, paint, and composite — and why reflow is the most expensive step.

The Runtime Theory Team3 min read#rendering#browser#layout#cssom#performance
On this page

When you ask "how does the browser render a page?", the honest answer is a pipeline of six stages: parse → style → layout → paint → composite, with the DOM/CSSOM construction folded into parse. Every tool you've used — Lighthouse, React DevTools, the Performance panel — is measuring a stage of this pipeline. The browser runs it once on load, then re-runs pieces of it on every change. Knowing which piece re-runs, and why, is the difference between a "fast" page and a page that thrashes the compositor at 30 fps.

Parse: bytes to DOM and CSSOM

HTML arrives as bytes. The parser tokenizes it into a stream of start tags, end tags, and text, then builds the DOM (Document Object Model) — a tree of Node objects that JavaScript can inspect and mutate. Crucially, HTML parsing is streaming and incremental: the browser can paint part of the page before the full document arrives. CSS parsing runs the same way but builds a separate tree, the CSSOM. Unlike HTML, CSS is render-blocking: the browser will not paint until the CSSOM is ready, because painting without styles would produce a flash of unstyled content.

<script> tags without defer or async pause HTML parsing while they execute, because scripts can mutate the DOM with document.write. That pause is the real cost of a blocking script: it stalls every downstream stage, not just parsing.

Style: matching rules to elements

With the DOM and CSSOM in hand, the browser computes the computed style for every element: it walks each element, matches it against the stylesheet rules, and resolves cascading and inheritance. This is the "style recalc" you see in DevTools. Matching is fast in modern engines (rules are indexed by the rightmost selector, so the engine only tests plausible candidates), but the cascade — resolving !important, specificity, and inheritance for each property — is real work proportional to the number of elements and rules.

Layout: geometry, not pixels

Layout (historically "reflow") computes the geometry: the x/y position and width/height of every box. It walks the tree in document order, lays out text into lines, wraps boxes, and resolves percentages, auto sizes, and flex/grid tracks. Layout is the stage where one change can cascade: changing the width of a container can re-layout every descendant, and changing the document width re-layouts everything. Geometry is a tree property — paint is not.

Paint and composite: pixels

Paint rasterizes each layer: it draws text, borders, shadows, backgrounds, and images into bitmaps, honoring paint order (stacking contexts, z-index). Composite then assembles the layers on the GPU, applying transforms and opacity. The reason compositing exists is that the GPU can transform a layertransform: translateX(10px) moves a bitmap without re-painting it. That's why animating transform and opacity is cheap and animating width or top is expensive: the former touches only the composite stage, the latter forces layout and paint.

Reflow vs repaint

The three "cost classes" of a change are:

  1. Composite-only (transform, opacity): the GPU repositions an existing bitmap. Cheapest.
  2. Repaint (color, background, box-shadow): pixels are redrawn in place. Layout is untouched.
  3. Reflow/layout (width, font-size, top, display): geometry changes, so layout re-runs, and then paint and composite follow automatically. Most expensive — and layout is synchronous: the browser cannot paint a stale geometry, so any later read of a layout property (offsetWidth, getBoundingClientRect) after a pending change forces the full layout to run immediately.

Layout thrashing

The classic mistake is interleaving writes and reads:

js
for (const item of items) {
  item.style.width = `${(i / items.length) * 100}%`; // write → layout dirtied
  const height = item.offsetHeight;                  // read → forced synchronous layout
}

Each read after a write forces a full layout pass, so the loop above runs layout N times. Batching the reads into a separate pass runs it once:

js
const heights = items.map((item) => item.offsetHeight); // read pass
items.forEach((item, i) => { item.style.width = ... }); // write pass

Layout runs once. On a page with a few thousand nodes, that's the difference between 10 ms and 100+ ms of main-thread time.

Why the pipeline matters

Every performance tool you'll ever read is this pipeline wearing different clothes. Lighthouse's "render-blocking resources" is the CSSOM waiting on network. The Performance panel's long tasks are main-thread stages exceeding the frame budget. CLS is layout happening after paint. Once you can name the stage, you can name the fix.