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 layer — transform: 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:
- Composite-only (transform, opacity): the GPU repositions an existing bitmap. Cheapest.
- Repaint (color, background, box-shadow): pixels are redrawn in place. Layout is untouched.
- 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:
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:
const heights = items.map((item) => item.offsetHeight); // read pass
items.forEach((item, i) => { item.style.width = ... }); // write passLayout 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.