02 — The Browser Rendering Pipeline
Overview
Every pixel displayed on a browser tab is the result of a multi-stage pipeline that transforms plain text (HTML, CSS, JavaScript) into rasterized pixels on the screen. This pipeline is one of the most performance-critical paths in software: it must complete in under 16ms to achieve 60 frames per second, while handling documents of arbitrary complexity written by developers who do not always understand the performance implications of their choices. Understanding the pipeline in detail is the foundation of performance engineering for the web.
Historical Context
Early browsers (Mosaic, Netscape 1.x) had no concept of a rendering pipeline. Parsing and painting were interleaved in a single pass: as each HTML token was parsed, it was immediately drawn to screen. CSS did not exist; styling was inline attributes. Layout was table-based with no floats, no flexbox, no transforms.
As the web became richer, engines were forced to separate concerns. The introduction of CSS2 (1998) with its cascade, specificity, inheritance, and box model required a style engine separate from the HTML parser. Absolute and relative positioning required a dedicated layout pass. CSS animations and hardware compositing arrived in the 2010s and required the pipeline to be split further to avoid unnecessary work.
Chrome's current rendering engine, Blink (forked from WebKit in 2013), has undergone a decade of restructuring toward the RenderingNG architecture (shipped 2021–2022), which makes the pipeline more parallelizable, more incremental, and more correct.
Pipeline Overview
HTML source bytes
│
▼
┌──────────────┐
│ 1. PARSING │ HTML Tokenizer → Tree Construction → DOM tree
│ │ CSS Parser → CSSOM tree
└──────┬───────┘
│
▼
┌──────────────────┐
│ 2. STYLE CALC │ Cascade + Specificity + Inheritance
│ │ → Computed Style for every DOM node
└──────┬───────────┘
│
▼
┌──────────────┐
│ 3. LAYOUT │ Geometry calculation: x, y, width, height
│ (Reflow) │ → Layout tree (formerly Render tree)
└──────┬───────┘
│
▼
┌──────────────┐
│ 4. PRE-PAINT│ Property tree construction
│ │ (transform / clip / effect / scroll trees)
└──────┬───────┘
│
▼
┌──────────────┐
│ 5. PAINT │ Draw commands per layer → Display list
└──────┬───────┘
│
▼
┌──────────────────┐
│ 6. COMPOSITING │ Tile rasterization (GPU or CPU)
│ │ Layer tree → final frame
└──────┬───────────┘
│
▼
GPU scanout → screen pixels
Stage 1: Parsing
HTML Parsing
The HTML parser is specified by the WHATWG HTML Living Standard as a state machine. Unlike XML, HTML parsing is intentionally fault-tolerant: every error has a defined recovery behavior. The parser never throws a fatal error; it emits a DOM tree even for severely malformed markup.
Tokenization produces tokens: DOCTYPE, start tag, end tag, comment, character data. The tree construction algorithm uses an "open elements" stack and "active formatting elements" list to handle misnested tags, missing close tags, and other violations.
Input: <p>Hello <b>world</p>
↑ </b> is missing — parser inserts it
Output DOM:
└─ p
├─ "Hello "
└─ b
└─ "world"
The HTML parser is not re-entrant with JavaScript. When the parser encounters a <script>
tag without async or defer, parsing halts, the script is fetched and executed, and parsing
resumes. This is the "parser-blocking script" problem and is the reason <script> tags are
conventionally placed at the bottom of <body>.
The preload scanner runs ahead of the main parser in a second pass, scanning ahead in the
token stream to discover <link>, <img>, and <script> resources and dispatch fetches
before the main parser reaches them.
CSS Parsing
CSS is parsed in parallel with HTML (CSS does not block HTML parsing, but does block rendering). The CSS parser produces a CSSOM (CSS Object Model): a tree of stylesheet rules, each rule containing selectors and a list of declarations.
Stage 2: Style Calculation
Style calculation takes the DOM tree and CSSOM and computes a computed style for every DOM node. This is a three-step process:
- Cascade: Collect all matching rules for a node, ordered by origin (user-agent, user,
author), importance (
!important), and specificity. The winning declaration for each property is selected. - Inheritance: Properties marked as inherited (e.g.,
color,font-size) flow from parent to child nodes when not explicitly set. - Value computation: Relative values are resolved to absolute ones:
em→px,currentColor→ actual color value,inherit→ parent's computed value.
Performance: Selector Complexity
The most expensive selectors are those that require traversing large parts of the tree. The universal selector combined with descendant combinators is notoriously slow:
/* Slow: for every element, walk all ancestors to check */
div > * + * { ... }
/* Fast: class selector with no combinators */
.my-widget { ... }
Modern style engines use rule trees and style sharing (Gecko) or style invalidation (Blink) to avoid recomputing styles for nodes whose ancestry hasn't changed.
Stage 3: Layout (Reflow)
Layout computes the geometry of every box in the layout tree. The layout tree is built from the DOM + computed styles: display:none nodes are excluded, replaced content (images, video) gets their intrinsic dimensions, and pseudo-elements are inserted.
Layout Tree (simplified):
┌─────────────────────────────────────┐
│ Block: <body> │
│ x:0 y:0 w:1440 h:800 │
│ ┌──────────────┐ ┌─────────────┐ │
│ │ Block: <div> │ │ Block: <div>│ │
│ │ x:0 y:0 │ │ x:720 y:0 │ │
│ │ w:720 h:400 │ │ w:720 h:400 │ │
│ └──────────────┘ └─────────────┘ │
└─────────────────────────────────────┘
Layout algorithms vary by formatting context:
- Block formatting context (BFC): Vertical stacking of block boxes. Margins collapse.
- Inline formatting context: Inline boxes flow left-to-right, wrapped into line boxes.
- Flexbox: Flex items distributed along main and cross axes based on flex-grow/shrink/basis.
- Grid: Two-dimensional placement into named grid areas and track sizes.
- Floats: Boxes pulled out of normal flow, inline content wraps around them.
Layout is incremental: Blink marks subtrees as "dirty" when styles change and relays out
only the dirty subtrees. However, certain CSS properties (e.g., changes to width on a parent)
invalidate large portions of the tree. This is a "forced synchronous layout" or "layout
thrashing" when triggered repeatedly within a single frame.
Layout Thrashing
Reading a layout property (e.g., element.offsetWidth) after writing a style forces the engine
to flush the pending layout synchronously:
// BAD: layout thrashing
elements.forEach(el => {
el.style.width = el.parentNode.offsetWidth + 'px'; // read forces layout, write invalidates
});
// GOOD: batch reads, then batch writes
const widths = elements.map(el => el.parentNode.offsetWidth);
elements.forEach((el, i) => el.style.width = widths[i] + 'px');
Stage 4: Pre-Paint and Property Trees
Pre-paint is a step introduced in RenderingNG that builds property trees: separate trees for transforms, clips, effects (opacity/filter/blend), and scrolling. These trees capture the hierarchical relationships between these properties independent of the layout tree structure.
Property trees enable the compositor to apply transforms and opacity changes without re-layout and re-paint — they are the data structure that makes GPU-accelerated animations efficient.
Stage 5: Paint
Paint converts the layout tree into paint operations — a display list of drawing commands: "draw rectangle at x,y with color #fff", "draw text glyph at x,y", "clip to rect", etc.
Paint does not produce pixels. It produces a serializable list of commands that can be replayed on any rasterizer (software Skia, GPU-backed Skia via Ganesh, or the new Skia GPU backend "Graphite").
Layers are painted separately. A layer corresponds to a "composited layer" — a region of the document that will be promoted to its own GPU texture.
Stage 6: Compositing
Compositing is the stage where the output of rasterization is assembled into a final frame.
Layer Promotion
Not every DOM element becomes a separate composited layer. Layer promotion occurs when:
- The element has a CSS
transformapplied. - The element has
opacityless than 1. - The element has
will-change: transformorwill-change: opacity. - The element is a
<video>,<canvas>, or<iframe>. - The element has a CSS filter.
- The element is a 3D-transformed child of a 3D context.
DOM Layer tree (compositor)
─── ───────────────────────
└─ <body> Layer 0 (root)
├─ <nav> └─ contains: nav, header, footer
├─ <header>
├─ <div class="hero" Layer 1 (promoted: transform)
│ style="transform:.."> └─ contains: hero div
├─ <main> Layer 0 (root, continued)
└─ <footer> └─ contains: main, footer
Why Compositing Avoids Re-paint
When an animated element is on its own layer, changing its transform or opacity requires
only a compositor thread operation: the GPU re-composites the existing textures with the
new transform matrix. The main thread (parser, JS, layout, paint) is not involved. This is why
transform and opacity animations are "jank-free" even when the main thread is busy.
Contrast with margin-top animation: changing margin-top requires layout (geometry changes),
then paint (new display list), then rasterization, then compositing. All four stages run on the
main thread or require main thread coordination.
Compositor thread: Main thread:
───────────────── ────────────
transform animation ←──── (busy with JavaScript)
runs at 60fps (no (no involvement needed)
even under JS load) sync)
Tile Rasterization
Large layers are divided into tiles (typically 256×256 or 512×512 pixels). Tiles are rasterized independently, allowing the GPU to prioritize visible tiles (viewport-adjacent) over tiles far off-screen. The tile system is managed by the cc (compositor client) library in Chromium.
Rendering Optimizations
CSS Containment
The contain CSS property tells the browser that a subtree is independent of the rest of the
document:
.widget {
contain: layout style paint;
}
layout: layout changes inside.widgetdo not affect the outside.style: style counter and quote changes don't escape.paint: the element is a stacking context; overflow is clipped.
With containment, a dirty subtree can be relaid out and repainted without triggering global layout or global paint.
content-visibility: auto
content-visibility: auto skips layout and paint for off-screen elements entirely:
.article-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* estimated height while skipped */
}
Sections below the fold are not laid out until they approach the viewport. This can reduce
initial page load rendering time by 5–10x for long documents. The contain-intrinsic-size
prevents scroll bar jumping by providing an estimated height.
GPU Layer Promotion Pitfalls
Over-promotion is a common mistake. Creating thousands of composited layers:
- Consumes GPU memory (each layer is a texture).
- Increases compositing cost (more textures to blend per frame).
- Can degrade performance on mobile GPUs.
Use browser DevTools → Layers panel to audit layer count and identify unexpected promotions.
Debugging the Rendering Pipeline
Chrome DevTools — Performance panel: Record a performance trace to see each stage of the pipeline annotated per frame. Look for: - Long "Recalculate Style" tasks (complex selectors or large CSSOM). - Long "Layout" tasks (forced synchronous layout, complex reflows). - "Paint" and "Composite Layers" timings.
chrome://tracing:
The full Blink renderer trace with sub-millisecond granularity. Categories:
blink, blink.rendering, cc, gpu reveal the full pipeline.
will-change audit:
Search for excessive will-change usage. Every element with will-change: transform is
promoted, consuming GPU memory even if never animated.
Security Implications
The rendering pipeline is a rich source of memory safety vulnerabilities:
- Parser bugs: HTML/CSS parsers handle malformed input by design. Off-by-one errors in the tokenizer state machine have historically led to heap corruption.
- Font rendering: Complex font shaping (OpenType GSUB/GPOS) involves table parsing in the renderer process — a common target for exploit chains.
- SVG/CSS filter processing: Complex filter graphs (feBlend, feComposite) can trigger integer overflows in the paint stage.
Because all rendering happens in the sandboxed renderer process, exploitation requires a subsequent sandbox escape to reach the OS.
Performance Implications Summary
| Stage | Trigger | Cost | Mitigation |
|---|---|---|---|
| Style Recalc | CSS rule change | O(n selectors) | Simpler selectors, CSS containment |
| Layout | Geometry property change | O(n dirty subtree) | Avoid forced sync layout |
| Paint | Visual property change | O(dirty layer area) | CSS containment, avoid large layers |
| Compositing | Transform/opacity change | GPU only | Preferred animation path |
Future Directions
LayoutNG: Blink's new layout engine (shipping incrementally since 2019) rewrites the layout algorithms with a clean separation between input (constraint space) and output (fragment tree). Enables parallel layout for independent subtrees.
PaintWorklet / Houdini CSS Paint API: Allows custom paint operations written in JavaScript that execute at the paint stage — enabling custom backgrounds, borders, and effects without polluting the main rendering pipeline with JavaScript layout thrashing.
Off-thread compositing: Chromium is moving more compositing decisions off the main thread entirely, using worker threads to build the layer tree in parallel with JavaScript execution.
Exercises
- Use Chrome DevTools Performance panel to record a scroll event on a page with a sticky header. Identify whether the sticky header triggers paint or only compositing. Explain why.
- Build a page with 1000 absolutely positioned
<div>elements. Measure the layout time. Addcontain: strictto each. Measure again. Explain the difference. - Animate
margin-topvstransform: translateY()on the same element. Record both in the Performance panel. Identify which triggers layout and which does not. - Use the
content-visibility: autoproperty on a 500-section document. Measure Time to Interactive before and after. Document the tradeoffs. - Inspect
chrome://tracingfor theblink.renderingcategory during a page load. Identify the sequence of ParseHTML, UpdateLayoutTree, Layout, Paint, and Commit events.
References
- Grigorik, I. "Browser Rendering Optimization." Google Web Fundamentals (2014).
- Chrome RenderingNG Architecture:
https://developer.chrome.com/articles/renderingng/ - WHATWG HTML Parser Specification:
https://html.spec.whatwg.org/multipage/parsing.html - Stannard, C. "How Browsers Work."
https://web.dev/howbrowserswork/(2011, still canonical) - CSS Containment Level 2 Specification:
https://www.w3.org/TR/css-contain-2/ content-visibilityexplainer:https://web.dev/content-visibility/