How Browsers Render (and Where It Janks)
intermediate20 min readLesson 105 of 143
Parse β style β layout β paint β composite. Knowing which step your code triggers tells you what it costs.
Every visual change walks a pipeline. The step that re-runs determines the cost.
The pipeline
- Parse β HTML becomes the DOM; CSS becomes the CSSOM. Scripts can block parsing (hence
defer/async). - Style β compute which CSS rules apply to each element (the "Recalculate Style" you see in DevTools).
- Layout β geometry: where every element sits, how big it is. A change to size or position invalidates layout.
- Paint β fill pixels: text, colors, shadows, images.
- Composite β GPU-assembles painted layers. Cheapest of all.
Cost ordering matters: a transform or opacity change can stop at composite (cheap). A width/top/font-size change walks styleβlayoutβpaint (expensive β it's on every element it affects, and children). color/background skips layout but still paints.
Jank and the frame budget
Browsers aim for 60fps β every frame gets a 16.7ms budget (10ms on 120Hz displays). A script task that runs 200ms freezes that many frames: the page visibly stutters. Long tasks come from big loops, huge DOM mutations, or layout thrashing.
Layout thrashing: the classic performance bug
// BAD: read-write-read-write forces layout every iteration
for (const card of cards) {
const h = card.offsetHeight; // READ β forces layout (previous write is pending)
card.style.height = h + 20 + "px"; // WRITE β invalidates layout
}
The fix is batching: read everything, then write everything.
const heights = cards.map((c) => c.offsetHeight); // all reads
cards.forEach((c, i) => {
c.style.height = heights[i] + 20 + "px";
}); // all writes
What actually moves the needle
visibility/opacitytransitions instead of size animationscontent-visibility: autofor long off-screen sections- Fewer, larger DOM mutations (DocumentFragment, one reflow)
- The Composite-only properties (
transform,opacity) for animation