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
ScreenStage 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/deferto 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 DOMContentLoadedStage 3 — Render tree
DOM + CSSOM combined. Only visible elements:
display: noneis excludedvisibility: hiddenstays (still takes space)- Everything under
headis 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 reflowStage 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 onlyStage 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
| Change | Pipeline | Cost |
|---|---|---|
width, height, top | Layout → Paint → Composite | Expensive ❌ |
color, background | Paint → Composite | Medium |
transform, opacity | Composite only | Cheapest ✅ |
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 perceivedForcing 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)ortranslate3dposition: fixed<video>/<canvas>opacity < 1during animationwill-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 layoutoffsetWidth, 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
- web.dev — Rendering Performance — web.dev
- CSS Triggers (Paul Irish) — csstriggers.com
- Layout thrashing — web.dev
- Core Web Vitals — web.dev
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-changereserves 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.