Skip to content
yutils

How Browsers Render Pages

From HTML bytes to pixels — parsing, DOM/CSSOM, render tree, layout, paint, composite. Why reflow is expensive, what triggers GPU layers, and how to stay inside the 16.67ms frame budget.

~8 min read

What happens between "HTML bytes arrive" and "pixels on screen"? Not a single "render" step — a pipeline: parsing → DOM/CSSOM → render tree → layout → paint → composite. Knowing where it slows down is how you stay inside the 16.67 ms-per-frame budget. This guide walks through the six stages, the reflow / repaint distinction, and how GPU layers work.

The Critical Rendering Path — six stages

HTML bytes
   ↓ parse
DOM tree
   ↓ + CSS parse
CSSOM tree
   ↓ combine
Render tree    (DOM + applied styles, display:none excluded)
   ↓ layout
Geometry (each box's x/y/width/height)
   ↓ paint
Pixel data
   ↓ composite
Screen

Stage 1-2 — Parsing (DOM + CSSOM)

The browser turns bytes into tokens → nodes → trees:

  • HTML parser builds DOM (see the how-html-parsing-works guide)
  • CSS parser builds CSSOM
  • CSSOM is render-blocking — no render without CSS (avoid FOUC)
  • Scripts are parser-blocking by default — DOM construction pauses for script execution. Use async / defer to avoid this
<script src="..."></script>        ← parser blocking
<script async src="..."></script>  ← downloads in parallel, runs ASAP
<script defer src="..."></script>  ← downloads in parallel, runs before DOMContentLoaded

Stage 3 — Render tree

DOM + CSSOM combined. Only visible elements:

  • display: none is excluded
  • visibility: hidden stays (still takes space)
  • Everything under head is excluded

Stage 4 — Layout (= reflow)

Compute exact box positions and sizes:

  • Document flow, box model (margin/border/padding), float, position
  • Flex / grid calculation
  • Text wrap, line breaks

Expensive — changing one element's size cascades through children, siblings, and ancestors. Large pages can spend tens of ms here.

Triggers reflow:

width / height / top / left / right / bottom
margin / padding / border
font-size / line-height
display / position / float
↓
All trigger reflow

Stage 5 — Paint

Once geometry is set, the browser draws pixels:

  • Background colors / images
  • Border styles
  • Text rendering (font glyph compositing)
  • box-shadow / outline

Triggers paint without layout:

color
background
visibility
outline
box-shadow
↓
No geometry change → skip layout, paint only

Stage 6 — Composite

Painted layers are composited on the GPU:

  • Elements may live on separate layers → GPU handles transform / opacity directly
  • CPU is freed; frame rate climbs

GPU-only changes (skip layout AND paint):

transform: translate / scale / rotate
opacity
↓
60fps naturally. The cheapest animations.

Three change classes and their cost

ChangePipelineCost
width, height, topLayout → Paint → CompositeExpensive ❌
color, backgroundPaint → CompositeMedium
transform, opacityComposite onlyCheapest ✅

Animate with transform / opacity. Don't move things via top.

The 16.67 ms-per-frame budget

1000 ms / 60 = 16.67 ms per frame

Subtract browser overhead (input handling, etc.) ~2-3 ms.
Remaining ~13 ms = JavaScript + rendering.

Targets:
- JavaScript: < 8 ms
- Style / Layout / Paint / Composite: < 5 ms

Exceeding budget → dropped frame → jank perceived

Forcing GPU layers — will-change

/* smooth hover transform */
.card {
  transition: transform 0.3s;
  will-change: transform;     ← request a GPU layer up front
}

.card:hover {
  transform: scale(1.05);
}

will-change reserves a layer. Don't sprinkle it everywhere — each layer costs memory.

Implicit GPU-layer triggers:

  • transform: translateZ(0) or translate3d
  • position: fixed
  • <video> / <canvas>
  • opacity < 1 during animation
  • will-change: transform | opacity

Layout thrashing — the common trap

// Bad
for (let i = 0; i < boxes.length; i++) {
  boxes[i].style.width = boxes[i].offsetWidth + 10 + 'px';
  //                                ↑ read           ↑ write
  // Read forces layout → write invalidates → next read forces again
  // → N layouts
}

// Good — batch reads, then writes
const widths = boxes.map(b => b.offsetWidth);   // all reads first
boxes.forEach((b, i) => b.style.width = widths[i] + 10 + 'px');
// → 1 layout

offsetWidth, getBoundingClientRect, scrollTop — geometry reads. Called after a write they trigger forced synchronous layout.

Core Web Vitals tie back to rendering

  • LCP (Largest Contentful Paint) — time until the largest element renders. Target ≤ 2.5 s.
  • INP (Interaction to Next Paint) — time from user input to next frame. Target ≤ 200 ms.
  • CLS (Cumulative Layout Shift) — accumulated unexpected layout shifts. Target ≤ 0.1.

Common CLS culprit — images without width/height. Browser reserves zero space; once loaded, content shifts. Specify aspect-ratio or explicit width/height.

Common pitfalls

1. Animating top/left

Reflows every frame. Swap for transform.

2. Large box-shadow + animation

Shadow is paint-expensive. Hover shadows on big elements struggle to stay at 60fps.

3. Forced sync layout

Mixing reads and writes. Batch with requestAnimationFrame.

4. FOIT (font load)

Without font-display: swap, text stays invisible until the webfont arrives. Use swap to show fallback then replace.

5. Excessive will-change

Sprinkling it everywhere wastes memory. Only apply to elements that actually animate.

References

Summary

  • Six stages — parse → DOM/CSSOM → render tree → layout → paint → composite.
  • Layout (reflow) is the expensive part. width/height/top/left changes trigger it.
  • Paint-only changes — color/background/visibility/box-shadow.
  • Composite-only (GPU) — transform/opacity. The 60fps standard.
  • 16.67 ms-per-frame budget. ~8 ms JS + ~5 ms rendering.
  • will-change reserves GPU layers in advance — use sparingly.
  • Layout thrashing = alternating read/write forces sync layouts. Batch reads first.
  • Core Web Vitals (LCP / INP / CLS) — set image dimensions, animate with transform, use font-display: swap.
Back to guides