Concept
Modern browsers are extraordinarily complex. Chrome has ~35 million lines of code. But the part that frontend engineers need to own deeply is the rendering pipeline, the sequence of steps that converts HTML bytes into visible pixels. Every Core Web Vitals metric maps directly to a stage in this pipeline.
Browser process architecture
Chrome (and most modern browsers) use a multi-process architecture for security and stability:
- Browser process, UI (tabs, address bar), coordinates everything
- Renderer process, one per tab (isolated). Runs the HTML parser, JS engine, layout engine, painter. This is where your code runs.
- GPU process, handles compositing and drawing to screen
- Network process, handles all network I/O (separate from renderer for security)
- Plugin/extension processes, isolated to prevent crashes from taking down tabs
The renderer process runs in a sandbox, it can't access the filesystem or OS directly. This is why a compromised JavaScript context can't exfiltrate files.
The critical rendering path
Bytes → Characters → Tokens → Nodes → DOM
↘
CSS bytes → CSSOM ──────────────────── Render Tree → Layout → Paint → Composite
↗
[JavaScript can block both]Step 1: Parse HTML → DOM
The HTML parser is not a simple left-to-right reader. Key behaviors:
- Incremental: parsing starts as soon as the first bytes arrive, before the full document is downloaded
- Recoverable: HTML parsers handle malformed markup via error recovery rules (unlike XML)
- Blocked by scripts: a classic
<script>tag withoutasync/deferpauses the parser, because scripts can calldocument.write()which injects more HTML
While the main parser is blocked, a background preload scanner speculatively fetches resources it finds in the unparsed HTML (<img>, <link rel="stylesheet">, <script>). This is why even render-blocking resources start downloading early.
Step 2: Parse CSS → CSSOM
CSS Object Model is built from all stylesheets (external + <style> + inline). Unlike HTML, CSS parsing is not incremental, the browser won't render anything until all CSS is downloaded and parsed, because a later rule can override an earlier one.
The CSSOM is the reason:
<link rel="stylesheet">in<head>blocks rendering@importinside CSS is especially bad, it's discovered late and creates additional blocking loadsmediaattributes can make stylesheets non-render-blocking:<link media="print">doesn't block
Step 3: Render tree
The render tree is the DOM + CSSOM merged, with non-visual nodes removed:
display: noneelements → removed from render tree (but still in DOM)visibility: hiddenelements → kept in render tree (they still occupy space)<head>,<script>,<meta>→ not in render tree
Each render tree node is a layout object (a.k.a. "box" in the CSS spec).
Step 4: Layout (Reflow)
Layout calculates the position and size of every element on the page. It starts at the root and recurses.
Layout is expensive, a change to one element can invalidate the layout of many others (e.g., changing a parent's width reflowing all its children). This is called forced synchronous layout or "layout thrashing" when triggered repeatedly in JavaScript.
// Layout thrashing, reads and writes interleaved, forces layout on every iteration
for (const el of elements) {
const height = el.offsetHeight; // READ, forces layout to flush
el.style.height = height + 10 + "px"; // WRITE, invalidates layout
}
// Fix: batch reads first, then writes
const heights = elements.map(el => el.offsetHeight); // all reads
heights.forEach((h, i) => { elements[i].style.height = h + 10 + "px"; }); // all writesStep 5: Paint
Paint converts the render tree into draw calls, the instructions for filling in pixels. These are grouped into paint layers. Elements that are independently animated (using transform, opacity) get their own compositor layer, which lets the GPU update them without repainting other layers.
What triggers paint: Any visual change that doesn't qualify for composite-only, background color changes, border changes, box-shadow changes, etc.
Step 6: Composite
The compositor takes all the painted layers and combines ("composites") them into the final screen image, on the GPU. Compositing is done off the main thread, this is what makes transform and opacity animations buttery smooth even when the main thread is busy.
Properties that are compositor-only (don't trigger layout or paint):
transform(translate, rotate, scale)opacityfilter(on GPU-promoted layers)will-change: transform(promotes element to its own layer)
/* Prefer this, compositor only, no layout/paint */
.slide-in {
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
/* Avoid this, triggers layout on every frame */
@keyframes badSlide {
from { left: -100px; }
to { left: 0; }
}JavaScript and the main thread
JavaScript runs on the main thread, the same thread that handles HTML parsing, layout, and painting. A long JS task (> 50ms) blocks all of these. This is why:
- Long JS tasks increase INP (Interaction to Next Paint)
requestAnimationFrameschedules work just before paint- Web Workers move computation off the main thread (but can't touch the DOM)
scheduler.postTask()(and React'sstartTransition) defer non-urgent work
Common Mistakes
1. Using left/top for animations
/* Triggers layout + paint on every frame, janky */
.bad { transition: left 0.3s; }
/* Compositor only, smooth 60fps even under load */
.good { transition: transform 0.3s; }2. Reading layout properties inside loops
offsetWidth, offsetHeight, clientWidth, getBoundingClientRect(), scrollTop, all of these force a synchronous layout flush if called after a DOM write. Interleaving reads and writes in a loop is called "layout thrashing."
3. Overusing will-change
will-change: transform creates a new GPU layer for that element. Layers have memory cost. Promoting everything to its own layer consumes VRAM and can actually slow down compositing.
/* Only add during animation, not permanently */
.animated { will-change: transform; }
/* Remove after animation ends with JavaScript */4. CSS @import in stylesheets
An @import inside a CSS file is only discovered after the parent stylesheet downloads. It creates a waterfall of blocking requests. Always use <link> tags for each stylesheet, never @import.
5. Large DOM trees
A DOM with >1,500 nodes increases layout time, paint time, and memory. Virtualize long lists (react-virtual, TanStack Virtual) instead of rendering all rows.
Best Practices
- Animate only
transformandopacityfor 60fps animations. - Use
content-visibility: autoon off-screen sections, tells the browser to skip layout/paint until they're near the viewport. - Defer non-critical CSS, use
<link media="print" onload="this.media='all'">trick or load asynchronously. - Minimize DOM depth and size. More nodes = more work on every layout pass.
- Use
requestAnimationFramefor JS-driven visual updates, it runs just before the next frame, never mid-render. - Measure layout cost with the Chrome DevTools Performance panel, look for purple "Layout" bars in the flame chart.
Performance Tips
- The browser targets 60fps, each frame has ~16.6ms budget. Layout + paint + script must all fit in that budget.
- Layer promotion has cost. Each GPU layer uses VRAM. Don't promote elements that don't need it.
contain: layoutisolates layout, changes inside the element don't trigger layout recalculation outside it. Huge win for widget-heavy pages.- Chrome's "Layers" panel (DevTools → More tools → Layers) shows you exactly how many compositor layers you have and why each was created.
ResizeObserveris cheaper thanwindow.resize, it only fires when a specific element's size changes, not on every window resize event.
