frontend / performance / 02_critical_rendering_path.md

The Critical Rendering Path and the Rendering Pipeline

7 min read source

The Critical Rendering Path and the Rendering Pipeline

TL;DR

The browser turns bytes into pixels through a pipeline: parse HTML → build DOM, parse CSS → build CSSOM, combine into render tree, layout, paint, composite. Any step can stall first paint (LCP) or cause re-runs (jank). Know which CSS properties trigger which step — transform/opacity are composite-only (cheap); width/height/top/left trigger layout (expensive). The “critical rendering path” is what runs before the first paint; minimizing it = faster LCP.

Interview Q&A

Q: Walk me through the rendering pipeline.

A:

HTML bytes ─▶ tokenize ─▶ DOM tree

CSS bytes ─▶ tokenize ─▶ CSSOM ─▶ Render Tree ─▶ Layout ─▶ Paint ─▶ Composite ─▶ pixels

JS (modifies DOM/CSSOM)
  • DOM — parsed HTML into a tree of elements.
  • CSSOM — parsed CSS into a tree of style rules.
  • Render tree — visible elements + their computed styles (skips display: none).
  • Layout (reflow) — computes the position and size of every box. Geometry.
  • Paint — fills in pixels (colors, text, borders, images) onto layers.
  • Composite — assembles painted layers into the final frame, often on the GPU.

JS execution can mutate DOM/CSSOM, forcing re-layout/repaint downstream.

Q: What’s the “critical rendering path”?

A: The sequence of resources and processing steps the browser must complete before showing the first useful pixels. Minimizing it = faster FCP/LCP.

Three optimization levers:

  1. Reduce critical resources — fewer render-blocking CSS/JS.
  2. Reduce critical bytes — minify, compress (gzip/brotli), inline small things.
  3. Reduce critical path length — fewer round trips, parallelize what you can.

Q: What’s render-blocking? How do you fix it?

A:

  • CSS is render-blocking by default — the browser won’t paint until CSSOM is built (otherwise it’d flash unstyled). Fix: inline critical CSS in <head>, load the rest async via <link rel="preload" as="style" onload="this.rel='stylesheet'"> or media="print" onload="..." hack.
  • Synchronous <script> in <head> is render-blocking and parser-blocking — stops HTML parsing too. Fix: async (load in parallel, execute as soon as ready, doesn’t block parsing but may execute out of order) or defer (load in parallel, execute after parsing in order).
<!-- Bad — blocks parsing -->
<script src="app.js"></script>

<!-- Good — most scripts -->
<script src="app.js" defer></script>

<!-- For independent third-party scripts -->
<script src="analytics.js" async></script>

defer is the right default for app code; async for independent third-party things like analytics.

Q: Layout vs Paint vs Composite — which CSS properties trigger which?

A:

Property change Triggers
width, height, padding, margin, top, left, display layout → paint → composite
color, background-color, box-shadow, border-color, visibility paint → composite
transform, opacity, filter (with will-change/promoted layer) composite only

The hierarchy: layout is the most expensive, composite is the cheapest. Animate transform/opacity instead of top/left/width — same visual effect, no layout.

/* Bad — animates layout property */
.box { transition: top 0.3s; }
.box.moved { top: 100px; }

/* Good — animates compositor property */
.box { transition: transform 0.3s; }
.box.moved { transform: translateY(100px); }

Q: What’s “layout thrashing” and how do you avoid it?

A: Reading layout properties (offsetTop, getBoundingClientRect, scrollHeight) forces a synchronous layout if a write happened since the last layout flush. Doing this in a loop:

// Layout thrashing — N layouts!
for (const el of items) {
  const w = container.offsetWidth;        // read — forces layout
  el.style.width = w / 2 + "px";          // write — invalidates layout
  // next iteration's read forces another layout
}

// Fixed — batch reads, then writes
const w = container.offsetWidth;          // single read
for (const el of items) {
  el.style.width = w / 2 + "px";          // writes, layout flushes once at frame end
}

requestAnimationFrame is the right place for the “write” phase; the browser flushes layout once before the next paint, not per iteration.

Q: How does compositor-only animation actually work?

A: The browser promotes some elements to their own compositor layer (a GPU texture). Layout/paint happen on the layer; animations apply transform matrices to the layer’s position/opacity. The GPU composites layers each frame — no main-thread work, can hit 60fps trivially.

Triggers to promote to a layer:

  • transform: translateZ(0) or translate3d(0,0,0) (legacy “hack”).
  • will-change: transform, opacity (modern).
  • position: fixed in some browsers.
  • Video, canvas, iframe.

Don’t over-promote — each layer has memory cost (texture size × screen). A page with hundreds of will-change elements wastes GPU memory and can be slower.

Q: What’s content-visibility: auto?

A: A CSS property that tells the browser skip rendering (layout + paint) of an off-screen element until it’s near the viewport. Big win for long pages where most content is off-screen.

.long-section {
  content-visibility: auto;
  contain-intrinsic-size: 1000px 500px;    /* placeholder size to prevent CLS */
}

contain-intrinsic-size is the reserved box dimension while the content is “skipped.” Without it, the element collapses to zero and the page scrolls weird.

Pair with content-visibility for: long article archives, gallery cells below the fold, virtualized lists where you want CSS to do the work.

Q: What’s the difference between display: none, visibility: hidden, and opacity: 0?

A:

In layout? Paints? Receives events? Compositor-only animation?
display: none no no no no
visibility: hidden yes (takes space) no no no
opacity: 0 yes yes (invisible) yes (still clickable) yes

For “fade out a modal,” opacity: 0 + transition is compositor-only and smooth. For “remove from layout entirely,” display: none (no transition possible — instant change).

Q: How does paint work?

A: The browser groups elements into paint records, then walks them to fill pixels into the layers’ bitmaps. Things that force more paint area:

  • Box shadow / filter — blur effects expand the paint region.
  • Text with custom fonts — re-paint when the font loads.
  • Element promoted to its own layer — paint cost moves but doesn’t disappear.

DevTools → Rendering → Paint Flashing highlights repaints in green; useful for spotting unnecessary paint.

Q: How does this connect to React/Vue rendering?

A: React/Vue’s “render” is computing the virtual DOM diff and producing DOM mutations. The browser’s “render” is everything from DOM mutation onward (style recalc, layout, paint, composite).

The framework can only do so much — once it hands the DOM mutation to the browser, the browser’s pipeline takes over. So:

  • React profiler shows you “this component took 50ms to render” — that’s the JS/diff phase.
  • Chrome DevTools Performance shows you “style recalc 20ms, layout 30ms, paint 10ms” — that’s the browser phase.

Both matter. A React render that creates many new DOM nodes causes a long layout phase. A React render that flips one className causes a tiny style recalc.

Gotchas / edge cases

  • CSS in @import is render-blocking and sequential — it can’t be parallelized like multiple <link rel="stylesheet">. Avoid @import for top-level CSS.
  • requestAnimationFrame callbacks fire before paint, not after — the right place to do reads/writes that need to land in the next frame.
  • Synchronous layout-triggering reads inside event handlers can drop a frame — defer to rAF if possible.
  • will-change is a promise to the browser that you’ll change this property — overuse wastes memory; underuse means no layer promotion. Use it just before the animation, remove after.
  • Text wrapping during font swap causes CLS — size-adjust / font-display: optional mitigate.
  • 3D transforms on text can blur it if the layer’s not pixel-aligned — transform: translateZ(0) is sometimes worse than no transform.

What a senior is expected to say

  • “Pipeline: parse → DOM/CSSOM → render tree → layout → paint → composite. Layout is expensive, composite is cheap — animate transform/opacity, not top/width.”
  • “Render-blocking CSS by default; defer non-critical CSS, inline the critical chunk. defer for app scripts, async for independent third-party.”
  • “Layout thrashing comes from interleaved reads and writes. Batch reads, then writes; use requestAnimationFrame.”
  • “Compositor layers via will-change or 3D transforms — use sparingly, each layer costs GPU memory.”
  • content-visibility: auto skips off-screen render — cheap perf win on long pages, paired with contain-intrinsic-size to prevent CLS.”
  • “Framework render = computing DOM diffs; browser render = everything downstream. Profile both: React Profiler for the JS half, Chrome Performance for the browser half.”

Cross-references

Further reading