The Critical Rendering Path and the Rendering Pipeline
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.
Walk me through the rendering pipeline.
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.
What’s the “critical rendering path”?
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.
What’s render-blocking? How do you fix it?
- 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.
Layout vs Paint vs Composite — which CSS properties trigger which?
| 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); }What’s “layout thrashing” and how do you avoid it?
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) {
// writes, layout flushes once at frame end
el.style.width = w / 2 + "px";
}requestAnimationFrame is the right place for the “write” phase; the browser flushes layout once before the next paint, not per iteration.
How does compositor-only animation actually work?
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.
What’s content-visibility: auto?
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.
What’s the difference between display: none, visibility: hidden, and opacity: 0?
| Layout and paint | Events, animation | |
|---|---|---|
display: none |
removed from layout; not painted | no events; no transition possible |
visibility: hidden |
keeps its space; not painted | no events; not compositor-only |
opacity: 0 |
keeps its space; painted invisible | still clickable; compositor-only |
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).
How does paint work?
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.
How does this connect to React/Vue rendering?
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
- 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.
Related
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
Interview angle 6
- “Walk through the rendering pipeline.” - parse HTML and CSS into the DOM
and CSSOM, combine into the render tree, lay out, paint, composite. The
practical takeaway is the cost gradient: layout is expensive, paint is
moderate, compositing is nearly free - which is why you animate
transformandopacityand nottoporwidth. - “What blocks the first paint?” - CSS, by default: the browser will not
render until it has the CSSOM, because it would otherwise paint unstyled
content. Inline the critical subset and defer the rest. For scripts,
deferfor your own code so it runs in order after parsing,asyncfor independent third-party tags. - “What is layout thrashing?” - reading a layout property after a write in
the same frame, which forces a synchronous recalculation. In a loop it is
quadratic. Batch all the reads, then all the writes, and do the writes in a
requestAnimationFrame. - “When should you create a compositor layer?” - sparingly.
will-changeor a 3D transform promotes an element so it can be composited independently, and each layer costs GPU memory. Promoting everything is a common own goal that makes scrolling worse on the devices you were trying to help. - “What is the cheapest win on a long page?” -
content-visibility: auto, which skips rendering work for offscreen subtrees entirely. Pair it withcontain-intrinsic-sizeso skipped content still reserves height and the scrollbar does not jump. - “How do you tell framework cost from browser cost?” - profile both. Framework rendering is computing what changed; browser rendering is everything downstream of that. The React Profiler measures the first, the Performance panel measures the second, and optimising the wrong one is common.