Browser Rendering Pipeline Deep Dive: From HTML to Pixels
Introduction: What Actually Happens When a Page Renders
When you load a webpage, the browser performs thousands of operations across multiple threads, coordinating parsing, style calculation, layout, paint, and compositing—all while trying to hit 60fps. Most developers understand this at a surface level: "HTML becomes DOM, CSS becomes styles, JavaScript runs." But at scale, the details matter.
Understanding the rendering pipeline isn't academic. It's the difference between a page that feels instant and one that stutters. When Netflix engineers optimized their browse page, they reduced "Input to Paint" from 450ms to 80ms by understanding exactly where in the pipeline their bottlenecks lived.
This article dissects each stage of the browser rendering pipeline—from network bytes to GPU-composited pixels—with focus on the internal data structures, threading model, and optimization opportunities that production engineers need to know.
The Complete Rendering Pipeline
┌─────────────────────────────────────────────────────────────────────────────┐
│ BROWSER RENDERING PIPELINE │
└─────────────────────────────────────────────────────────────────────────────┘
Network Main Thread Compositor Thread
│ │ │
▼ │ │
┌──────────┐ │ │
│ Bytes │ │ │
│ (HTML) │ │ │
└────┬─────┘ │ │
│ │ │
▼ │ │
┌──────────┐ │ │
│ Parse │──────────────────┤ │
│ HTML │ │ │
└────┬─────┘ │ │
│ │ │
▼ │ │
┌──────────┐ ┌──────────┐ │ │
│ DOM │◄──│ Parse │───┤ │
│ Tree │ │ CSS │ │ │
└────┬─────┘ └────┬─────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌──────────┐ │ │
│ │ CSSOM │ │ │
│ │ Tree │ │ │
│ └────┬─────┘ │ │
│ │ │ │
└──────────────┴─────────┤ │
│ │ │
▼ │ │
┌──────────┐ │ │
│ Style │────┤ │
│ Compute │ │ │
└────┬─────┘ │ │
│ │ │
▼ │ │
┌──────────┐ │ │
│ Layout │────┤ │
│ (Flow) │ │ │
└────┬─────┘ │ │
│ │ │
▼ │ │
┌──────────┐ │ │
│ Pre- │────┤ │
│ Paint │ │ │
└────┬─────┘ │ │
│ │ │
▼ │ │
┌──────────┐ │ │
│ Paint │────┤ │
│ (Record) │ │ │
└────┬─────┘ │ │
│ │ │
▼ │ │
┌──────────┐ │ ┌──────────────┐ │
│ Commit │────┼────────▶│ Tiling │─────────┤
└──────────┘ │ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Raster │─────────┤
│ │ (GPU) │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Draw │─────────┤
│ │ (Compose) │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Display │ │
│ │ (Pixels) │ │
│ └──────────────┘ │
Stage 1: HTML Parsing and DOM Construction
The Tokenizer: Bytes to Tokens
The HTML parser operates as a state machine defined by the WHATWG HTML specification. It reads bytes from the network stream and emits tokens.
Input: <div class="container"><p>Hello</p></div>
Tokenizer Output:
StartTag: div, attrs: [class="container"]
StartTag: p
Character: "Hello"
EndTag: p
EndTag: div
Internal State Machine (Simplified):
enum TokenizerState {
Data,
TagOpen,
TagName,
BeforeAttributeName,
AttributeName,
AfterAttributeName,
BeforeAttributeValue,
AttributeValueDoubleQuoted,
AttributeValueSingleQuoted,
AttributeValueUnquoted,
AfterAttributeValueQuoted,
SelfClosingStartTag,
MarkupDeclarationOpen,
// ... 70+ states in actual implementation
}
class HTMLTokenizer {
private state: TokenizerState = TokenizerState.Data;
private returnState: TokenizerState;
private currentToken: Token | null = null;
private buffer: string = '';
consume(char: string): Token[] {
const emittedTokens: Token[] = [];
switch (this.state) {
case TokenizerState.Data:
if (char === '<') {
this.state = TokenizerState.TagOpen;
} else {
// Emit character token
emittedTokens.push({ type: 'Character', data: char });
}
break;
case TokenizerState.TagOpen:
if (char === '/') {
this.state = TokenizerState.EndTagOpen;
} else if (isAlpha(char)) {
this.currentToken = { type: 'StartTag', tagName: '', attrs: [] };
this.buffer = char.toLowerCase();
this.state = TokenizerState.TagName;
}
break;
case TokenizerState.TagName:
if (isWhitespace(char)) {
this.currentToken.tagName = this.buffer;
this.state = TokenizerState.BeforeAttributeName;
} else if (char === '>') {
this.currentToken.tagName = this.buffer;
emittedTokens.push(this.currentToken);
this.currentToken = null;
this.buffer = '';
this.state = TokenizerState.Data;
} else {
this.buffer += char.toLowerCase();
}
break;
// ... 70+ more state handlers
}
return emittedTokens;
}
}
Tree Construction: Tokens to DOM
The tree builder consumes tokens and constructs the DOM tree. It maintains a stack of open elements and handles complex cases like implicit tag closing, foster parenting, and adoption agency algorithm.
class TreeBuilder {
private document: Document;
private openElements: Element[] = [];
private activeFormattingElements: Element[] = [];
private insertionMode: InsertionMode = InsertionMode.Initial;
processToken(token: Token): void {
switch (this.insertionMode) {
case InsertionMode.InBody:
this.processInBody(token);
break;
case InsertionMode.InTable:
this.processInTable(token);
break;
// ... 20+ insertion modes
}
}
private processInBody(token: Token): void {
if (token.type === 'StartTag') {
switch (token.tagName) {
case 'div':
case 'p':
case 'span':
// Check for implicit close
if (this.shouldCloseParagraph(token.tagName)) {
this.closeParagraph();
}
this.insertElement(token);
break;
case 'table':
if (this.hasElementInButtonScope('p')) {
this.closeParagraph();
}
this.insertElement(token);
this.insertionMode = InsertionMode.InTable;
break;
case 'script':
this.insertElement(token);
this.tokenizer.switchToScriptDataState();
this.originalInsertionMode = this.insertionMode;
this.insertionMode = InsertionMode.Text;
break;
}
}
}
private insertElement(token: StartTagToken): Element {
const element = this.document.createElement(token.tagName);
for (const attr of token.attrs) {
element.setAttribute(attr.name, attr.value);
}
this.currentNode().appendChild(element);
this.openElements.push(element);
return element;
}
private currentNode(): Element {
return this.openElements[this.openElements.length - 1];
}
}
Parser Blocking: Script and Stylesheet Handling
Synchronous Script Blocking:
<p>Before script</p>
<script src="app.js"></script> <!-- Parser blocks here -->
<p>After script</p>
When the parser encounters a synchronous script:
- Parser pauses
- If external: Network fetch begins
- Script executes (may use
document.write()) - Parser resumes
Why This Matters:
Timeline without script:
Parse HTML (50ms) → Complete
Timeline with blocking script:
Parse HTML (25ms) → Block → Fetch JS (200ms) → Execute (50ms) → Parse (25ms)
Total: 300ms
Speculative Parsing (Preload Scanner):
While blocked on a script, browsers run a secondary parser that scans ahead for resources to preload:
class PreloadScanner {
private pendingPreloads: PreloadRequest[] = [];
scan(html: string, startPosition: number): PreloadRequest[] {
const preloads: PreloadRequest[] = [];
let pos = startPosition;
while (pos < html.length) {
const match = this.findNextResource(html, pos);
if (!match) break;
if (match.type === 'script' && match.src) {
preloads.push({
url: match.src,
type: 'script',
priority: 'high',
});
} else if (match.type === 'link' && match.rel === 'stylesheet') {
preloads.push({
url: match.href,
type: 'style',
priority: 'high',
});
} else if (match.type === 'img') {
preloads.push({
url: match.src,
type: 'image',
priority: this.calculateImagePriority(match),
});
}
pos = match.endPosition;
}
return preloads;
}
}
Async and Defer:
<!-- Async: Download parallel, execute when ready (may interrupt parsing) -->
<script async src="analytics.js"></script>
<!-- Defer: Download parallel, execute after parsing complete, in order -->
<script defer src="app.js"></script>
Execution Order:
HTML:
<script defer src="a.js"></script>
<script async src="b.js"></script>
<script defer src="c.js"></script>
Possible execution order:
b.js (whenever it loads)
a.js (after DOMContentLoaded, before c.js)
c.js (after a.js)
Stage 2: CSS Parsing and CSSOM Construction
CSS Tokenization
CSS tokenization follows the CSS Syntax Level 3 specification:
.container { margin: 20px; }
Tokens:
<delim-token .>
<ident-token container>
<{-token>
<ident-token margin>
<colon-token>
<dimension-token 20px>
<semicolon-token>
<}-token>
Selector Parsing and Specificity Calculation
interface Specificity {
inline: number; // style="" attribute
ids: number; // #id
classes: number; // .class, [attr], :pseudo-class
elements: number; // element, ::pseudo-element
}
function calculateSpecificity(selector: string): Specificity {
const spec: Specificity = { inline: 0, ids: 0, classes: 0, elements: 0 };
// Count IDs
spec.ids = (selector.match(/#[a-zA-Z][\w-]*/g) || []).length;
// Count classes, attributes, pseudo-classes
spec.classes = (selector.match(/\.[a-zA-Z][\w-]*/g) || []).length;
spec.classes += (selector.match(/\[[^\]]+\]/g) || []).length;
spec.classes += (selector.match(/:[a-zA-Z][\w-]*/g) || []).length;
// Count elements, pseudo-elements
spec.elements = (selector.match(/^[a-zA-Z][\w-]*|(?<=\s)[a-zA-Z][\w-]*/g) || []).length;
spec.elements += (selector.match(/::[a-zA-Z][\w-]*/g) || []).length;
return spec;
}
// Comparison
function compareSpecificity(a: Specificity, b: Specificity): number {
if (a.inline !== b.inline) return a.inline - b.inline;
if (a.ids !== b.ids) return a.ids - b.ids;
if (a.classes !== b.classes) return a.classes - b.classes;
return a.elements - b.elements;
}
Specificity Examples:
| Selector | Specificity | Decimal |
|---|---|---|
* | 0,0,0,0 | 0 |
div | 0,0,0,1 | 1 |
.class | 0,0,1,0 | 10 |
div.class | 0,0,1,1 | 11 |
#id | 0,1,0,0 | 100 |
div#id.class | 0,1,1,1 | 111 |
style="" | 1,0,0,0 | 1000 |
CSSOM Tree Structure
interface CSSStyleRule {
selectorText: string;
specificity: Specificity;
style: CSSStyleDeclaration;
sourceOrder: number;
}
interface CSSStyleSheet {
rules: CSSStyleRule[];
disabled: boolean;
href: string | null;
media: MediaList;
}
class CSSOM {
private stylesheets: CSSStyleSheet[] = [];
addStylesheet(css: string, origin: 'author' | 'user' | 'ua'): void {
const parsed = this.parseStylesheet(css);
parsed.origin = origin;
this.stylesheets.push(parsed);
}
getMatchingRules(element: Element): CSSStyleRule[] {
const matches: CSSStyleRule[] = [];
for (const sheet of this.stylesheets) {
if (sheet.disabled) continue;
for (const rule of sheet.rules) {
if (this.selectorMatches(element, rule.selectorText)) {
matches.push(rule);
}
}
}
// Sort by specificity, then source order
return matches.sort((a, b) => {
const specCompare = compareSpecificity(a.specificity, b.specificity);
if (specCompare !== 0) return specCompare;
return a.sourceOrder - b.sourceOrder;
});
}
private selectorMatches(element: Element, selector: string): boolean {
// Use browser's native matching
return element.matches(selector);
}
}
Stage 3: Style Computation (Recalc)
Style computation resolves which CSS values apply to each DOM element, considering:
- Cascade - Which rules win (specificity, origin, importance)
- Inheritance - Which properties inherit from parent
- Initial Values - Defaults for non-inherited properties
- Computed Values - Resolving relative units, currentColor, etc.
ComputedStyle Structure
interface ComputedStyle {
// Layout
display: 'block' | 'inline' | 'flex' | 'grid' | 'none' | ...;
position: 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky';
width: number; // Computed pixel value
height: number;
margin: [number, number, number, number]; // TRBL
padding: [number, number, number, number];
// Visual
backgroundColor: Color;
color: Color;
opacity: number;
transform: Matrix4x4 | null;
// Text
fontFamily: string[];
fontSize: number;
fontWeight: number;
lineHeight: number;
// Flags (computed from various properties)
createsStackingContext: boolean;
requiresCompositingLayer: boolean;
isPositioned: boolean;
}
Style Invalidation
When DOM or CSS changes, the browser must recalculate styles. This can be expensive.
class StyleInvalidator {
private dirtyElements: Set<Element> = new Set();
private subtreeInvalidations: Set<Element> = new Set();
invalidateElement(element: Element, reason: InvalidationReason): void {
switch (reason) {
case InvalidationReason.ClassChange:
// Only this element needs recalc
this.dirtyElements.add(element);
break;
case InvalidationReason.IdChange:
// This element + descendants (selectors like #id .child)
this.subtreeInvalidations.add(element);
break;
case InvalidationReason.PseudoClassChange:
// May affect siblings (:first-child, :nth-child, etc.)
this.invalidateSiblings(element);
break;
case InvalidationReason.StylesheetChange:
// Full document recalc
this.subtreeInvalidations.add(document.documentElement);
break;
}
}
recalculateStyles(): void {
// Process subtree invalidations first
for (const root of this.subtreeInvalidations) {
this.recalcSubtree(root);
}
// Then individual elements
for (const element of this.dirtyElements) {
if (!this.wasRecalculated(element)) {
this.recalcElement(element);
}
}
this.dirtyElements.clear();
this.subtreeInvalidations.clear();
}
}
Style Recalc Cost:
| Operation | Typical Cost | Notes |
|---|---|---|
| Single element recalc | 0.1-0.5ms | Selector matching + cascade |
| Class change | 0.1-1ms | Depends on selector complexity |
| Stylesheet insertion | 10-100ms | Full document recalc |
| Large DOM mutation | 5-50ms | Depends on affected subtree size |
Stage 4: Layout (Reflow)
Layout calculates the geometry (position, size) of every element. This is where display, position, width, height, margin, padding, flex, grid take effect.
Layout Tree vs DOM Tree
The layout tree differs from the DOM tree:
display: noneelements have no layout node::before,::afterpseudo-elements create layout nodes- Anonymous boxes are created (e.g., inline content wrapped in block)
- Some elements generate multiple layout boxes (e.g.,
<li>with list markers)
class LayoutTree {
private root: LayoutBox;
build(dom: Document, styles: Map<Element, ComputedStyle>): LayoutBox {
return this.createLayoutBox(dom.documentElement, styles);
}
private createLayoutBox(
element: Element,
styles: Map<Element, ComputedStyle>
): LayoutBox | null {
const style = styles.get(element);
// No layout box for display: none
if (style.display === 'none') {
return null;
}
const box = new LayoutBox(element, style);
// Create pseudo-element boxes
if (style.hasBefore) {
box.children.unshift(this.createPseudoBox('before', style));
}
if (style.hasAfter) {
box.children.push(this.createPseudoBox('after', style));
}
// Process children
for (const child of element.children) {
const childBox = this.createLayoutBox(child, styles);
if (childBox) {
// May need anonymous boxes
if (this.needsAnonymousWrapper(box, childBox)) {
const wrapper = this.createAnonymousBox();
wrapper.children.push(childBox);
box.children.push(wrapper);
} else {
box.children.push(childBox);
}
}
}
return box;
}
}
Block Layout Algorithm
class BlockLayouter {
layout(box: LayoutBox, containingBlock: Rect): void {
// Resolve width
box.width = this.resolveWidth(box, containingBlock);
// Layout children and calculate height
let currentY = box.padding.top;
for (const child of box.children) {
// Resolve child's containing block
const childContainingBlock = {
x: box.padding.left,
y: currentY,
width: box.width - box.padding.left - box.padding.right,
height: Infinity, // Available height
};
// Layout child
this.layoutChild(child, childContainingBlock);
// Margin collapsing
const marginTop = this.collapseMargins(
this.lastChildMarginBottom,
child.margin.top
);
child.y = currentY + marginTop;
currentY = child.y + child.height + child.margin.bottom;
this.lastChildMarginBottom = child.margin.bottom;
}
// Resolve height
box.height = this.resolveHeight(box, currentY + box.padding.bottom);
}
private resolveWidth(box: LayoutBox, containing: Rect): number {
const style = box.style;
if (style.width !== 'auto') {
return this.resolveLength(style.width, containing.width);
}
// Auto width: fill available space
return containing.width - box.margin.left - box.margin.right;
}
private collapseMargins(margin1: number, margin2: number): number {
// Adjacent vertical margins collapse
if (margin1 >= 0 && margin2 >= 0) {
return Math.max(margin1, margin2);
}
if (margin1 < 0 && margin2 < 0) {
return Math.min(margin1, margin2);
}
return margin1 + margin2;
}
}
Flexbox Layout Algorithm
class FlexLayouter {
layout(flexContainer: LayoutBox): void {
const items = flexContainer.children;
const style = flexContainer.style;
// Step 1: Calculate flex base sizes
for (const item of items) {
item.flexBaseSize = this.calculateFlexBaseSize(item);
item.hypotheticalMainSize = Math.max(
item.minMainSize,
Math.min(item.flexBaseSize, item.maxMainSize)
);
}
// Step 2: Collect items into flex lines
const lines = this.collectIntoLines(items, flexContainer.mainSize);
// Step 3: Resolve flexible lengths
for (const line of lines) {
this.resolveFlexibleLengths(line, flexContainer.mainSize);
}
// Step 4: Calculate cross sizes
for (const line of lines) {
this.calculateCrossSizes(line);
}
// Step 5: Align items
for (const line of lines) {
this.alignItems(line, style.alignItems);
}
// Step 6: Align lines (for multi-line)
if (lines.length > 1) {
this.alignLines(lines, style.alignContent, flexContainer.crossSize);
}
// Step 7: Position items
for (const line of lines) {
this.justifyContent(line, style.justifyContent, flexContainer.mainSize);
}
}
private resolveFlexibleLengths(line: FlexLine, availableSpace: number): void {
const usedSpace = line.items.reduce(
(sum, item) => sum + item.hypotheticalMainSize,
0
);
const freeSpace = availableSpace - usedSpace;
if (freeSpace > 0) {
// Distribute positive free space according to flex-grow
const totalGrow = line.items.reduce(
(sum, item) => sum + item.style.flexGrow,
0
);
for (const item of line.items) {
const ratio = item.style.flexGrow / totalGrow;
item.mainSize = item.hypotheticalMainSize + freeSpace * ratio;
}
} else if (freeSpace < 0) {
// Shrink items according to flex-shrink
const totalShrink = line.items.reduce(
(sum, item) => sum + item.style.flexShrink * item.flexBaseSize,
0
);
for (const item of line.items) {
const scaledShrinkFactor = item.style.flexShrink * item.flexBaseSize;
const ratio = scaledShrinkFactor / totalShrink;
item.mainSize = item.hypotheticalMainSize + freeSpace * ratio;
item.mainSize = Math.max(item.mainSize, item.minMainSize);
}
}
}
}
Layout Thrashing
Layout is expensive. Forcing layout synchronously causes "layout thrashing":
// BAD: Forces layout on every iteration
for (const el of elements) {
el.style.width = el.offsetWidth + 10 + 'px'; // Read (forces layout)
// Style change invalidates layout
// Next read forces another layout
}
// GOOD: Batch reads and writes
const widths = elements.map(el => el.offsetWidth); // Single layout
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px'; // Write only
});
// Layout happens once at next frame
Properties That Force Layout:
| Category | Properties |
|---|---|
| Element Geometry | offsetWidth, offsetHeight, offsetTop, offsetLeft, offsetParent |
| Scroll | scrollTop, scrollLeft, scrollWidth, scrollHeight |
| Client | clientWidth, clientHeight, clientTop, clientLeft |
| Box Model | getComputedStyle(), getBoundingClientRect() |
| Window | innerWidth, innerHeight, scrollX, scrollY |
Stage 5: Pre-Paint
Pre-paint is a preparation stage before painting where the browser:
- Builds property trees - Transform, clip, effect, scroll trees
- Determines compositing - Which elements get their own layers
- Invalidates paint - Marks dirty regions
Property Trees
Modern browsers use property trees instead of a single flattened transform:
Property Tree Example:
DOM: Transform Tree:
┌─────────────────────┐
│ <html> │ TransformNode (root)
│ <body> │ │
│ <div A> │ ├─ TransformNode (A: translate(10px))
│ <div B> │ │ │
│ </div> │ │ └─ TransformNode (B: rotate(45deg))
│ </div> │ │
│ <div C> │ └─ TransformNode (C: scale(2))
│ </div> │
│ </body> │
│ </html> │
interface TransformNode {
id: number;
parent: TransformNode | null;
localTransform: Matrix4x4;
children: TransformNode[];
// Cached values
cachedWorldTransform: Matrix4x4 | null;
}
class TransformTree {
private nodes: Map<Element, TransformNode> = new Map();
getWorldTransform(node: TransformNode): Matrix4x4 {
if (node.cachedWorldTransform) {
return node.cachedWorldTransform;
}
if (!node.parent) {
node.cachedWorldTransform = node.localTransform;
} else {
const parentWorld = this.getWorldTransform(node.parent);
node.cachedWorldTransform = parentWorld.multiply(node.localTransform);
}
return node.cachedWorldTransform;
}
invalidate(node: TransformNode): void {
node.cachedWorldTransform = null;
for (const child of node.children) {
this.invalidate(child);
}
}
}
Compositing Triggers
Elements are promoted to their own compositing layer when they:
function requiresCompositingLayer(element: LayoutBox): boolean {
const style = element.computedStyle;
// Explicit compositing hints
if (style.willChange.includes('transform') ||
style.willChange.includes('opacity')) {
return true;
}
// 3D transforms
if (style.transform && style.transform.has3D()) {
return true;
}
// Animated properties
if (element.hasActiveAnimation(['transform', 'opacity'])) {
return true;
}
// Fixed/sticky positioning
if (style.position === 'fixed' || style.position === 'sticky') {
return true;
}
// Video, canvas, WebGL
if (element.tagName === 'VIDEO' ||
element.tagName === 'CANVAS' ||
element.hasWebGLContext) {
return true;
}
// Overlap with composited layer
if (element.overlapsCompositedContent()) {
return true; // Layer squashing may apply
}
return false;
}
Layer Cost:
| Layer Scenario | Memory Cost | GPU Cost |
|---|---|---|
| Simple layer (300x200) | ~240KB | Minimal |
| Full-screen layer (1920x1080) | ~8.3MB | Moderate |
| 10 animated layers | ~25MB | High |
| Layer explosion (100+ layers) | ~250MB+ | Very High |
Stage 6: Paint (Recording)
Paint records drawing operations into a display list. It doesn't actually draw pixels yet.
Display List Operations
interface DisplayItem {
type: DisplayItemType;
bounds: Rect;
visualRect: Rect; // Includes effects like shadows
}
enum DisplayItemType {
DrawRect,
DrawRoundedRect,
DrawText,
DrawImage,
DrawShadow,
BeginClip,
EndClip,
BeginTransform,
EndTransform,
BeginOpacity,
EndOpacity,
}
class PaintContext {
private displayList: DisplayItem[] = [];
drawRect(rect: Rect, paint: Paint): void {
this.displayList.push({
type: DisplayItemType.DrawRect,
bounds: rect,
visualRect: this.expandForEffects(rect, paint),
paint,
});
}
drawText(text: string, position: Point, font: Font, paint: Paint): void {
const metrics = font.measureText(text);
const bounds = {
x: position.x,
y: position.y - metrics.ascent,
width: metrics.width,
height: metrics.height,
};
this.displayList.push({
type: DisplayItemType.DrawText,
bounds,
visualRect: this.expandForEffects(bounds, paint),
text,
font,
paint,
});
}
save(): void {
// Push state for clipping, transforms, etc.
}
restore(): void {
// Pop state
}
clipRect(rect: Rect): void {
this.displayList.push({
type: DisplayItemType.BeginClip,
clipRect: rect,
});
}
translate(x: number, y: number): void {
this.displayList.push({
type: DisplayItemType.BeginTransform,
transform: Matrix4x4.translate(x, y, 0),
});
}
}
Paint Order (Stacking)
Paint order follows stacking context rules:
function paintElement(element: LayoutBox, ctx: PaintContext): void {
// 1. Background and borders of current element
paintBackgroundAndBorders(element, ctx);
// 2. Descendants with negative z-index (in z-index order)
const negativeZIndex = element.children
.filter(c => c.style.zIndex < 0)
.sort((a, b) => a.style.zIndex - b.style.zIndex);
for (const child of negativeZIndex) {
paintElement(child, ctx);
}
// 3. Non-positioned, non-floating descendants (in tree order)
const normalFlow = element.children
.filter(c => !c.style.isPositioned && !c.style.isFloat);
for (const child of normalFlow) {
paintElement(child, ctx);
}
// 4. Floating descendants
const floats = element.children
.filter(c => c.style.isFloat);
for (const float of floats) {
paintElement(float, ctx);
}
// 5. Inline content of current element
paintInlineContent(element, ctx);
// 6. Positioned descendants with z-index: auto or 0 (in tree order)
const positioned = element.children
.filter(c => c.style.isPositioned && (c.style.zIndex === 'auto' || c.style.zIndex === 0));
for (const child of positioned) {
paintElement(child, ctx);
}
// 7. Descendants with positive z-index (in z-index order)
const positiveZIndex = element.children
.filter(c => c.style.zIndex > 0)
.sort((a, b) => a.style.zIndex - b.style.zIndex);
for (const child of positiveZIndex) {
paintElement(child, ctx);
}
}
Stage 7: Commit
Commit transfers data from the main thread to the compositor thread. This includes:
- Display lists for each layer
- Property tree updates
- Scroll offsets
- Animation state
class Commit {
private layerUpdates: Map<LayerId, DisplayList> = new Map();
private propertyTreeUpdates: PropertyTreeUpdate;
private scrollUpdates: Map<ScrollableId, Point> = new Map();
transferToCompositor(): CompositorFrame {
// Serialize data for IPC to compositor process
return {
layers: this.serializeLayers(),
propertyTrees: this.propertyTreeUpdates,
scrolls: this.scrollUpdates,
timestamp: performance.now(),
};
}
}
Stage 8: Tiling
Large layers are divided into tiles for efficient GPU memory management and partial updates.
class TileManager {
private tileSize: number = 256; // 256x256 pixels typical
private tiles: Map<string, Tile> = new Map();
getTilesForLayer(layer: Layer, viewport: Rect): Tile[] {
const visibleTiles: Tile[] = [];
const startCol = Math.floor(viewport.x / this.tileSize);
const endCol = Math.ceil((viewport.x + viewport.width) / this.tileSize);
const startRow = Math.floor(viewport.y / this.tileSize);
const endRow = Math.ceil((viewport.y + viewport.height) / this.tileSize);
for (let row = startRow; row < endRow; row++) {
for (let col = startCol; col < endCol; col++) {
const tileKey = `${layer.id}:${col}:${row}`;
let tile = this.tiles.get(tileKey);
if (!tile) {
tile = this.createTile(layer, col, row);
this.tiles.set(tileKey, tile);
}
visibleTiles.push(tile);
}
}
return visibleTiles;
}
private createTile(layer: Layer, col: number, row: number): Tile {
return {
x: col * this.tileSize,
y: row * this.tileSize,
width: this.tileSize,
height: this.tileSize,
layer: layer.id,
needsRaster: true,
texture: null,
};
}
}
Stage 9: Rasterization
Rasterization converts vector display list operations into pixels (GPU textures).
GPU Rasterization
class GPURasterizer {
private gl: WebGL2RenderingContext;
private rasterPool: RasterWorker[];
async rasterTile(tile: Tile, displayList: DisplayItem[]): Promise<GPUTexture> {
// Clip display list to tile bounds
const clippedItems = this.clipToTile(displayList, tile);
if (clippedItems.length === 0) {
return this.emptyTexture;
}
// Create framebuffer for tile
const framebuffer = this.createFramebuffer(tile.width, tile.height);
// Render display items
for (const item of clippedItems) {
switch (item.type) {
case DisplayItemType.DrawRect:
this.drawRectToGPU(item, framebuffer);
break;
case DisplayItemType.DrawText:
// Text is rasterized by CPU, then uploaded
const textBitmap = await this.rasterTextCPU(item);
this.uploadTexture(textBitmap, framebuffer, item.bounds);
break;
case DisplayItemType.DrawImage:
this.drawImageToGPU(item, framebuffer);
break;
}
}
return framebuffer.texture;
}
private drawRectToGPU(item: DrawRectItem, fb: Framebuffer): void {
const shader = this.getRectShader(item.paint);
this.gl.useProgram(shader.program);
this.gl.uniform4fv(shader.uniforms.rect, [
item.bounds.x, item.bounds.y,
item.bounds.width, item.bounds.height,
]);
this.gl.uniform4fv(shader.uniforms.color, item.paint.color.toArray());
if (item.paint.borderRadius) {
this.gl.uniform4fv(shader.uniforms.borderRadius, item.paint.borderRadius);
}
this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
}
}
Rasterization Priority
class RasterPriorityQueue {
private highPriority: Tile[] = []; // Visible tiles
private mediumPriority: Tile[] = []; // Near viewport
private lowPriority: Tile[] = []; // Prefetch
prioritize(tiles: Tile[], viewport: Rect, scrollVelocity: Point): void {
for (const tile of tiles) {
if (this.intersects(tile, viewport)) {
this.highPriority.push(tile);
} else if (this.isInPredictedViewport(tile, viewport, scrollVelocity)) {
this.mediumPriority.push(tile);
} else {
this.lowPriority.push(tile);
}
}
// Sort high priority by distance from viewport center
this.highPriority.sort((a, b) =>
this.distanceFromCenter(a, viewport) - this.distanceFromCenter(b, viewport)
);
}
private isInPredictedViewport(
tile: Tile,
viewport: Rect,
velocity: Point
): boolean {
// Predict viewport position in next 200ms
const predictedViewport = {
x: viewport.x + velocity.x * 0.2,
y: viewport.y + velocity.y * 0.2,
width: viewport.width,
height: viewport.height,
};
return this.intersects(tile, predictedViewport);
}
}
Stage 10: Draw (Compositing)
The compositor combines all rasterized layers into the final image.
class Compositor {
private gl: WebGL2RenderingContext;
draw(frame: CompositorFrame): void {
// Clear screen
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
// Sort layers by paint order
const sortedLayers = this.sortLayers(frame.layers);
for (const layer of sortedLayers) {
// Apply layer transforms from property tree
const transform = frame.propertyTrees.transform.getWorldTransform(layer.transformNode);
// Apply layer effects (opacity, filters, blend modes)
const opacity = frame.propertyTrees.effect.getOpacity(layer.effectNode);
// Draw tiles
for (const tile of layer.tiles) {
if (!tile.texture) continue;
this.drawTile(tile, transform, opacity);
}
}
// Swap buffers
this.gl.flush();
}
private drawTile(tile: Tile, transform: Matrix4x4, opacity: number): void {
const shader = this.tileShader;
this.gl.useProgram(shader.program);
this.gl.uniformMatrix4fv(shader.uniforms.transform, false, transform.toArray());
this.gl.uniform1f(shader.uniforms.opacity, opacity);
this.gl.activeTexture(this.gl.TEXTURE0);
this.gl.bindTexture(this.gl.TEXTURE_2D, tile.texture);
this.gl.uniform4fv(shader.uniforms.tileBounds, [
tile.x, tile.y, tile.width, tile.height,
]);
this.gl.drawArrays(this.gl.TRIANGLES, 0, 6);
}
}
Frame Budget: Hitting 60fps
At 60fps, you have 16.67ms per frame. Here's a typical budget breakdown:
┌────────────────────────────────────────────────────────────────┐
│ 16.67ms Frame Budget │
├────────────────────────────────────────────────────────────────┤
│ │
│ Input (1ms) ████ │
│ JavaScript (6ms) ████████████████████ │
│ Style (1ms) ████ │
│ Layout (2ms) ████████ │
│ Paint (1ms) ████ │
│ Composite (2ms) ████████ │
│ Idle (3.67ms) ████████████ │
│ │
└────────────────────────────────────────────────────────────────┘
Frame Budget Breakdown:
| Stage | Target | Reality | Notes |
|---|---|---|---|
| Input handling | <1ms | 0.5-2ms | Event dispatch |
| JavaScript | <6ms | 1-100ms+ | Highly variable |
| Style | <1ms | 0.5-10ms | Depends on selector complexity |
| Layout | <2ms | 1-50ms+ | Depends on DOM size, changes |
| Pre-paint | <0.5ms | 0.2-2ms | Property tree updates |
| Paint | <1ms | 0.5-5ms | Display list recording |
| Composite | <2ms | 1-3ms | GPU compositing |
Optimization Strategies by Pipeline Stage
1. Reduce Parse Time
<!-- Defer non-critical CSS -->
<link rel="preload" href="critical.css" as="style">
<link rel="stylesheet" href="critical.css">
<link rel="preload" href="non-critical.css" as="style" onload="this.rel='stylesheet'">
<!-- Inline critical CSS -->
<style>
/* Above-the-fold styles */
</style>
2. Reduce Style Recalc
/* AVOID: Expensive selectors */
.container * { }
[class^="btn-"] { }
:nth-child(odd) { }
/* PREFER: Simple selectors */
.container-item { }
.btn-primary { }
.odd-row { }
3. Avoid Layout Thrashing
// Use requestAnimationFrame for DOM reads/writes
function updateElements(elements: Element[]) {
// Read phase
const measurements = elements.map(el => ({
el,
width: el.offsetWidth,
height: el.offsetHeight,
}));
// Write phase (all at once)
requestAnimationFrame(() => {
for (const { el, width, height } of measurements) {
el.style.transform = `translate(${width}px, ${height}px)`;
}
});
}
4. Minimize Paint Area
/* Isolate frequently-changing elements */
.animated-element {
will-change: transform;
contain: layout paint;
}
5. Optimize Compositing
/* Promote to layer for animations */
.card {
will-change: transform; /* Creates layer */
}
/* But avoid layer explosion */
.card:not(.animating) {
will-change: auto; /* Remove hint when not animating */
}
Debugging the Rendering Pipeline
Chrome DevTools Layers Panel
// Enable layer borders in DevTools
// Rendering > Layer borders
// Programmatic layer info
const layerCount = performance.getEntriesByType('element')
.filter(e => e.hasOwnProperty('renderingLayerCount'))
.reduce((sum, e) => sum + e.renderingLayerCount, 0);
Performance Markers
// Mark pipeline stages
performance.mark('render-start');
// After JavaScript
performance.mark('js-complete');
// After layout
requestAnimationFrame(() => {
performance.mark('layout-complete');
// After paint
requestAnimationFrame(() => {
performance.mark('paint-complete');
performance.measure('js-time', 'render-start', 'js-complete');
performance.measure('layout-time', 'js-complete', 'layout-complete');
performance.measure('paint-time', 'layout-complete', 'paint-complete');
});
});
Summary
The browser rendering pipeline transforms HTML, CSS, and JavaScript into pixels through a complex, multi-stage process:
- Parse - Convert bytes to DOM and CSSOM trees
- Style - Compute final styles for each element
- Layout - Calculate element geometry
- Pre-paint - Build property trees, determine compositing
- Paint - Record drawing operations to display lists
- Commit - Transfer to compositor thread
- Tile - Divide layers into manageable chunks
- Raster - Convert vectors to GPU textures
- Draw - Composite layers into final image
Understanding each stage allows you to:
- Identify bottlenecks using DevTools
- Apply targeted optimizations
- Avoid patterns that cause expensive operations
- Build applications that consistently hit 60fps
The pipeline is not a black box—it's a series of well-defined stages with specific costs and optimization strategies.
What did you think?