intermediate25 min read·Updated Dec 2024Expert reviewed
Browser Internals & Critical Rendering Path
The browser turns bytes into pixels through a precise pipeline: parse HTML → build DOM → parse CSS → build CSSOM → combine into render tree → layout → paint → composite. Knowing where this pipeline stalls is the foundation of every performance optimization.
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.
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.
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 without async/defer pauses the parser, because scripts can call document.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
@import inside CSS is especially bad — it's discovered late and creates additional blocking loads
media attributes 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: none elements → removed from render tree (but still in DOM)
visibility: hidden elements → 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 iterationfor (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 writesconst heights = elements.map(el => el.offsetHeight); // all readsheights.forEach
Step 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)
opacity
filter (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 {
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)
requestAnimationFrame schedules work just before paint
Web Workers move computation off the main thread (but can't touch the DOM)
scheduler.postTask() (and React's startTransition) defer non-urgent work
/* Triggers layout + paint on every frame — janky */.bad { transition: left 0.3s; }/* Compositor only — smooth 60fps even under load */.good { transition: transform 0.3s; }
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."
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 */
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.
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.