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:
- Reduce critical resources — fewer render-blocking CSS/JS.
- Reduce critical bytes — minify, compress (gzip/brotli), inline small things.
- 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'">ormedia="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) ordefer(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)ortranslate3d(0,0,0)(legacy “hack”).will-change: transform, opacity(modern).position: fixedin 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
@importis render-blocking and sequential — it can’t be parallelized like multiple<link rel="stylesheet">. Avoid@importfor top-level CSS. requestAnimationFramecallbacks 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-changeis 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: optionalmitigate. - 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.
deferfor app scripts,asyncfor independent third-party.” - “Layout thrashing comes from interleaved reads and writes. Batch reads, then writes; use
requestAnimationFrame.” - “Compositor layers via
will-changeor 3D transforms — use sparingly, each layer costs GPU memory.” - “
content-visibility: autoskips off-screen render — cheap perf win on long pages, paired withcontain-intrinsic-sizeto 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
- Core Web Vitals (the metrics this affects): 01_core_web_vitals.md
- Image/font perf (LCP causes live here): 07_image_and_font_optimization.md
- Resource hints to influence the path: 08_resource_hints.md
Further reading
- web.dev — Critical Rendering Path: https://web.dev/articles/critical-rendering-path
- web.dev —
content-visibility: https://web.dev/articles/content-visibility - CSS Triggers (which props trigger layout/paint/composite): https://csstriggers.com/
- Paul Lewis — “Avoid Large, Complex Layouts and Layout Thrashing”: https://developer.chrome.com/docs/devtools/performance/reference#layout-shifts