Rendering Pipeline Deep Dive
TL;DR
When the browser turns HTML/CSS/JS into pixels, it walks a pipeline: DOM + CSSOM → render tree → layout (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/opacitycan 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: transformorwill-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 blanket —
will-change: transformon every component is wasteful. - Animate the same property you declared.
will-change: transformthen animatingtopis 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:
- Layout — re-compute every sibling’s position (the box moved, may push others).
- Paint — re-paint the box and anything that moved.
- Composite — combine layers.
transform: translateY(100px) (on a promoted layer):
- (skipped) layout.
- (skipped) paint.
- 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/offsetParentclientTop/clientLeft/clientWidth/clientHeightscrollTop/scrollLeft/scrollWidth/scrollHeightgetBoundingClientRect()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-changeon 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/stickywithz-indexother thanauto.opacityless than1.transformother thannone.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
requestAnimationFramecan 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: nonedoesn’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 }foraddEventListenerto 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: transformfollowed 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: autoskips off-screen render — CSS-only perf win for long pages.” - “Stacking contexts explain ‘z-index doesn’t work’ bugs —
transformoropacity < 1on 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
- Performance angle (Web Vitals): ../15_performance/02_critical_rendering_path.md
- Event loop (rAF + paint integration): 01_event_loop.md
- Image / font handling: ../15_performance/07_image_and_font_optimization.md
Further reading
- web.dev — Rendering Performance: https://web.dev/articles/rendering-performance
- CSS Triggers (which props cause layout/paint/composite): https://csstriggers.com/
- Paul Lewis — “Avoid Large, Complex Layouts”: https://developer.chrome.com/docs/devtools/performance-insights
- “Stacking context” — MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/The_stacking_context