frontend / browser internals / 02_rendering_pipeline_deep.md

Rendering Pipeline Deep Dive

7 min read source

Rendering Pipeline Deep Dive

TL;DR

When the browser turns HTML/CSS/JS into pixels, it walks a pipeline: DOM + CSSOMrender treelayout (geometry) → paint (rasterize per layer) → composite (combine layers into the final frame). Each stage can be triggered selectively — changing color skips layout; changing transform skips layout and paint. Modern browsers run paint/composite off the main thread when possible (compositor thread + GPU). Understanding which property triggers which stage is the difference between 60fps animations and jank.

(The performance angle is in ../15_performance/02_critical_rendering_path.md; this file covers the mechanism more deeply.)

Interview Q&A

Q: Full pipeline — phase by phase.

A:

1. PARSE HTML → DOM tree (incremental, streamed)
2. PARSE CSS  → CSSOM tree
3. RENDER TREE = DOM ∩ CSSOM (visible elements + their computed styles)
4. LAYOUT (reflow) — compute geometry: x, y, width, height for every box
5. PAINT — fill pixels into layers (one bitmap per layer)
6. COMPOSITE — combine layers into the final framebuffer (often GPU-side)

JavaScript can mutate DOM/CSSOM at any point, forcing earlier stages to re-run.

Q: Which CSS properties trigger which stages?

A:

Property change Layout Paint Composite
width/height/padding/margin/top/left/display/flex-basis yes yes yes
color/background-color/border-color/box-shadow/visibility yes yes
transform/opacity/filter/backdrop-filter (on promoted layer) yes
font-size/font-family yes yes yes
text-align/text-decoration-color yes yes

The rule: layout-triggering changes are expensive, paint-only is cheaper, composite-only animations are essentially free (off the main thread).

Q: What’s a compositor layer?

A: A separate bitmap the browser paints into, then composites with other layers into the final frame. Promoting an element to its own layer means:

  • Changes to that element’s transform/opacity can be handled by the compositor (GPU) without re-paint or re-layout.
  • The element gets its own GPU texture in memory.

Triggers for promotion to a layer:

  • will-change: transform or will-change: opacity (modern, explicit).
  • 3D transforms (translateZ(0), translate3d(0,0,0) — legacy “hack”).
  • position: fixed (in some browsers).
  • <video>, <canvas>, <iframe>.
  • filter, backdrop-filter.
  • Hardware-accelerated CSS contexts (depending on the engine).

Don’t over-promote. Each layer:

  • Consumes GPU memory (= screen-area × 4 bytes × DPR²).
  • Adds composite work.
  • Can cause aliasing on text if the layer isn’t pixel-aligned.

A page with hundreds of promoted layers is usually slower, not faster.

Q: will-change — best practices.

A: Hint to the browser that an element is about to change:

.menu {
  will-change: transform;
}

Best practices:

  • Add just before the animation starts, remove after.
  • Don’t blanketwill-change: transform on every component is wasteful.
  • Animate the same property you declared. will-change: transform then animating top is useless.

For one-off animations, the JS pattern:

function animate(el) {
  el.style.willChange = "transform";
  // ... animation code ...
  el.addEventListener("transitionend", () => { el.style.willChange = ""; }, { once: true });
}

Q: transform vs top/left — show the cost difference.

A:

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

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

top: 100px forces:

  1. Layout — re-compute every sibling’s position (the box moved, may push others).
  2. Paint — re-paint the box and anything that moved.
  3. Composite — combine layers.

transform: translateY(100px) (on a promoted layer):

  1. (skipped) layout.
  2. (skipped) paint.
  3. Composite — translate the layer’s GPU texture.

The transform version runs on the compositor thread, hits 60fps trivially. The top version stalls under any non-trivial DOM.

Q: Synchronous layout — what triggers it from JS?

A: Any read of a layout property after a write forces an immediate layout so the read is accurate:

el.style.width = "100px";    // queue layout
const w = el.offsetWidth;     // FORCES layout NOW
el.style.width = "200px";    // queue another
const w2 = el.offsetWidth;    // FORCES layout AGAIN

The classic layout thrashing in a loop is N forced layouts where 1 batched layout would do.

Layout-forcing reads:

  • offsetTop/offsetLeft/offsetWidth/offsetHeight/offsetParent
  • clientTop/clientLeft/clientWidth/clientHeight
  • scrollTop/scrollLeft/scrollWidth/scrollHeight
  • getBoundingClientRect()
  • getClientRects()
  • getComputedStyle(el).width (any computed dimension)

Fix by batching: read all, then write all.

// Bad — thrashing
for (const el of els) {
  el.style.width = container.offsetWidth + "px";   // read after write
}

// Good — batched
const w = container.offsetWidth;                    // single read
for (const el of els) {
  el.style.width = w + "px";                        // writes only
}

Q: content-visibility — what does it do?

A: Tells the browser to skip rendering (layout + paint) of off-screen elements until they’re near the viewport. CSS-only “virtualization-lite”:

.row {
  content-visibility: auto;
  contain-intrinsic-size: 60px 100%;    /* placeholder size */
}

How it works:

  • The element’s box is rendered (so layout knows its size from contain-intrinsic-size).
  • The element’s contents are skipped (no layout/paint inside).
  • When the element approaches the viewport, the browser renders its contents.

Use cases: long article pages, gallery cells, large form sections off-screen. Big perf win with minimal code change.

Caveat: skipped content can’t be found by Ctrl+F until rendered. Modern browsers handle this; older may not.

Q: Paint records and layer composition.

A: Each layer’s paint is recorded as a list of “paint operations” (draw rect, draw text, draw image). The compositor combines layers into the final frame.

GPU does most of this for promoted layers:

  • Upload textures to GPU.
  • Apply transform matrices.
  • Blend layers.
  • Output to display.

Main thread is only involved when:

  • DOM/CSSOM changes.
  • A non-compositor-friendly property changes.
  • A layer needs to be re-rasterized (e.g., scaled beyond its texture’s resolution).

Animating transform: scale(...) may need re-rasterization at the new size if will-change: transform isn’t used — the browser sometimes pre-rasterizes at multiple scales.

Q: How does paint flashing in DevTools work?

A: Chrome DevTools → ⋮ → More tools → Rendering → Paint flashing.

Highlights any region that’s being repainted with a green flash. Catches:

  • Unnecessary paints (a CSS hover effect that paints the whole element instead of an isolated layer).
  • Scroll causing paint instead of composite (missing will-change on a parallax element).
  • Animation causing per-frame paint instead of composite-only.

Cheap diagnostic; use during animation/scroll debugging.

Q: Stacking context — what is it?

A: A 3D ordering of how layers stack on the Z axis (closer to the viewer = higher). Each stacking context has its own z-index space.

What creates a stacking context:

  • position: relative/absolute/fixed/sticky with z-index other than auto.
  • opacity less than 1.
  • transform other than none.
  • filter, backdrop-filter.
  • will-change: transform/opacity.
  • Various others.

Why this matters: z-index: 9999 inside a stacking context cannot exceed another stacking context’s z-index: 1 from outside. You debug “why is my modal behind this thing?” by walking up the stacking context tree.

Modal escape: portal to <body> (React createPortal, Vue <Teleport>) bypasses parent stacking contexts.

Gotchas / edge cases

  • Reading layout in requestAnimationFrame can still force layout if the previous frame’s writes are pending — schedule reads at the start of the rAF, writes at the end.
  • pointer-events: none doesn’t trigger paint but does affect hit-testing.
  • Off-thread paint (Chromium feature) moves more paint work off the main thread; reduces jank further.
  • Compositor scrolling — modern browsers scroll on the compositor thread, so JS can’t block scrolling unless you attach a synchronous wheel/touch listener. Use { passive: true } for addEventListener to opt into compositor scrolling.
  • filter: blur(10px) on a large element is expensive — paint cost is roughly proportional to area × blur radius.
  • Subpixel text on a compositor layer can blur — will-change: transform followed by removal sometimes helps; framework-specific.

What a senior is expected to say

  • “Pipeline: parse → DOM/CSSOM → render tree → layout → paint → composite. Layout-triggering changes are expensive; transform/opacity on a promoted layer skips both layout and paint, runs on the compositor thread, hits 60fps trivially.”
  • “Layer promotion via will-change (modern) or 3D transforms (legacy). Cost: GPU memory per layer. Don’t over-promote — each layer has overhead.”
  • “Layout thrashing comes from interleaved reads + writes. Batch all reads, then all writes — rAF is the right place to coordinate.”
  • content-visibility: auto skips off-screen render — CSS-only perf win for long pages.”
  • “Stacking contexts explain ‘z-index doesn’t work’ bugs — transform or opacity < 1 on an ancestor creates a new context that traps children’s z-indexes.”
  • “Compositor scrolling is the default unless you have synchronous wheel/touch listeners. Use { passive: true } to opt into smooth scroll.”

Cross-references

Further reading