How a browser renders a web page
How browsers parse HTML into a DOM tree, construct the CSSOM, build the render tree, perform layout calculations, and paint pixels through the compositing pipeline.
The Interview Question
Interviewer: "Your team is investigating why the largest contentful paint (LCP) of your landing page is over 4 seconds. Walk me through the entire pipeline that a browser uses to turn raw HTML bytes into visible pixels. Where in that pipeline could the bottleneck be?"
This question tests whether you understand the critical rendering path, not just at a buzzword level, but deeply enough to diagnose real performance problems. The interviewer wants to hear about parsing, tree construction, layout, paint, compositing, and where JavaScript and CSS can block the process.
What to Clarify Before Answering
You: "Before I trace the full pipeline, let me scope a few things..."
- "Are we focused on the initial page load, or does this include subsequent navigations and client-side rendering?"
- "Should I cover the network fetch and resource prioritization, or start from when the HTML bytes arrive?"
- "Do you want me to include GPU compositing and layer promotion, or stay at the DOM/CSSOM level?"
- "Should I address how modern frameworks (React, Next.js) change the pipeline with SSR and hydration?"
Why this matters: The rendering pipeline has at least six distinct stages. Each one has its own performance characteristics and optimization strategies. Scoping the answer lets you go deep where the interviewer actually cares, instead of spending five minutes on a shallow overview of everything.
The 30-Second Answer
When a browser receives HTML bytes, it runs them through a tokenizer that produces tokens, which a tree builder assembles into the DOM tree. As the parser encounters <link> and <style> tags, it constructs a parallel CSSOM tree from CSS rules. The browser then merges the DOM and CSSOM into a render tree (excluding invisible elements like display: none). The layout phase calculates the exact position and size of every box on the page. The paint phase records draw commands (fill rectangle, draw text, render image) into paint records. Finally, the compositing phase sends those records to the GPU as separate layers, which are rasterized and composited into the final frame. JavaScript can block this entire pipeline if it runs synchronously before the DOM is ready.
The Architecture Overview
This diagram shows the full pipeline from bytes to pixels. The key insight is that parsing, styling, layout, paint, and compositing are sequential stages. A bottleneck in any stage delays everything downstream. CSS blocks render tree construction. JavaScript blocks DOM parsing (unless marked async or defer). Layout is the most expensive stage for complex pages.
I will walk through each stage in detail, starting with how the parser turns raw bytes into a tree.
HTML Parsing: From Bytes to DOM Tree
The HTML parser is one of the most complex parsers in any software system. Unlike XML parsers, it must handle malformed markup gracefully because the web is full of broken HTML.
The tokenizer
The tokenizer is a state machine that reads the byte stream character by character. It produces five types of tokens:
- DOCTYPE tokens (the
<!DOCTYPE html>declaration) - Start tag tokens (
<div class="container">) - End tag tokens (
</div>) - Comment tokens (
<!-- ... -->) - Character/text tokens (everything between tags)
The tokenizer operates as a streaming process. It does not wait for the entire HTML document to arrive. As soon as it has enough bytes to form a token, it emits that token to the tree builder. This is why browsers can start rendering before the full page downloads.
// Simplified HTML tokenizer state machine
state = DATA
for each character in byte_stream:
switch(state):
case DATA:
if char == '<':
state = TAG_OPEN
else:
emit_character_token(char)
case TAG_OPEN:
if char == '/':
state = END_TAG_OPEN
else if char.is_alpha():
state = TAG_NAME
current_tag = char
case TAG_NAME:
if char == '>':
emit_start_tag_token(current_tag)
state = DATA
else if char == ' ':
state = ATTRIBUTE_NAME
else:
current_tag += char
The tree builder
The tree builder receives tokens and constructs the DOM tree using a stack-based algorithm. It maintains an "open elements stack" that tracks which elements are currently being built.
The tree builder also handles error correction. If you write <p>Hello<p>World, the parser automatically closes the first <p> before opening the second one. This is specified in the HTML living standard with over 80 different insertion modes.
Speculative parsing (preload scanner)
When the main parser hits a blocking <script> tag, a second lightweight parser called the preload scanner continues scanning the remaining HTML for resource URLs. It discovers <img>, <link>, <script>, and other resources and starts downloading them in the background.
Why this matters in production
The preload scanner is why <link rel="preload"> hints work. They give the browser a head start on critical resources. Without speculative parsing, every synchronous script would stall all resource discovery until that script finishes executing. The preload scanner typically saves 20-30% of total load time on script-heavy pages.
CSSOM Construction: Parsing and Computing Styles
While the HTML parser builds the DOM, the CSS parser (triggered by <style> blocks and <link rel="stylesheet"> tags) constructs the CSS Object Model (CSSOM).
How CSS parsing works
The CSS parser reads stylesheets and converts each rule into a structured representation. For each rule, it stores:
- Selector (what elements it matches)
- Declarations (property-value pairs)
- Specificity (how strongly the selector matches)
The parser resolves the cascade in this priority order:
- User agent stylesheet (browser defaults)
- Author stylesheets (your CSS)
- Inline styles (
style="...") !importantdeclarations (reversed cascade order)- Specificity within each origin (id > class > element)
- Source order for equal specificity (last rule wins)
Computed style resolution
After parsing, the browser computes the final style for every DOM node. This involves:
- Inheritance: Properties like
colorandfont-sizecascade from parent to child - Default values: Every property has an initial value if not set
- Relative units:
em,rem,%,vware resolved to pixel values - Shorthand expansion:
margin: 10pxbecomes four separate properties
CSS blocks rendering
CSS is render-blocking by default. The browser will not construct the render tree (and therefore will not paint anything) until all CSS stylesheets are downloaded and parsed. This is why large CSS files or slow CDN responses for stylesheets cause blank white screens. Use <link rel="preload" as="style"> for critical CSS and split non-critical CSS with media queries.
Render Tree and Layout: Computing the Geometry
Building the render tree
The render tree is the result of merging the DOM and CSSOM. For every visible DOM node, the browser creates a corresponding render object (called a "LayoutObject" in Chromium, or "Frame" in Firefox) with its computed styles attached.
Key rules for render tree construction:
display: noneelements are excluded entirely (not in the render tree)visibility: hiddenelements ARE included (they take up space but are invisible)opacity: 0elements ARE included (they exist in the layer tree)- Pseudo-elements (
::before,::after) are added to the render tree even though they are not in the DOM - The
<head>element and its children are always excluded
Layout (reflow)
Layout is the phase where the browser calculates the exact position and size of every render object. This is the most computationally expensive stage for complex pages.
The layout algorithm works top-down through the render tree:
// Simplified layout algorithm
function layout(node, availableWidth):
node.width = compute_width(node.style, availableWidth)
x_offset = node.padding.left
y_offset = node.padding.top
for child in node.children:
if child.style.display == "block":
child.x = x_offset
child.y = y_offset
layout(child, node.width - node.padding.horizontal)
y_offset += child.height + child.margin.vertical
else if child.style.display == "inline":
if x_offset + child.width > availableWidth:
// Line break: wrap to next line
x_offset = node.padding.left
y_offset += current_line_height
child.x = x_offset
child.y = y_offset
x_offset += child.width
node.height = y_offset + node.padding.bottom
Layout handles four box types:
| Box Type | Behavior | Examples |
|---|---|---|
| Block | Full width, stacks vertically, respects all margin/padding | div, p, h1, section |
| Inline | Width of content, flows horizontally, wraps at container edge | span, a, em, strong |
| Flex | Children sized/positioned by flex algorithm, main/cross axes | display: flex containers |
| Grid | Children placed in rows/columns by grid algorithm | display: grid containers |
Layout thrashing is the silent performance killer
Reading layout properties (like offsetWidth, getBoundingClientRect()) after modifying the DOM forces a synchronous reflow. If you do this in a loop (read, write, read, write), the browser cannot batch operations and must recalculate layout on every read. This pattern, called "layout thrashing", can turn a 1ms operation into a 100ms one. Always batch DOM reads together, then DOM writes together.
Paint and Compositing: Pixels on Screen
The paint phase
After layout, the browser knows the exact position and size of every element. The paint phase converts this geometry into paint records, which are low-level draw instructions.
Paint records look roughly like this:
// Paint records for a styled div
1. DrawRect(x: 100, y: 200, w: 400, h: 50, color: #f0f0f0) // background
2. DrawBorder(x: 100, y: 200, w: 400, h: 50, color: #333, width: 1px) // border
3. DrawText(x: 110, y: 220, text: "Hello World", font: 16px Arial, color: #000)
4. DrawImage(x: 350, y: 205, src: icon.png, w: 40, h: 40) // inline image
The browser paints in a specific stacking order (defined by the CSS painting order):
- Background colors and images
- Borders
- Content (text, replaced elements)
- Outline
- Children (recursively, respecting
z-indexand stacking contexts)
Layer creation and compositing
Modern browsers do not paint the entire page into a single bitmap. Instead, they split the page into compositor layers and send each layer to the GPU for independent rasterization.
Elements are promoted to their own compositor layer when they have:
transformoropacityanimations (orwill-change: transform)position: fixedorposition: sticky<video>,<canvas>, or<iframe>elements- CSS
filterorbackdrop-filter - Explicit
will-changeproperty - Overlapping content that requires its own stacking context
The key insight about compositing
When you animate transform or opacity, the browser skips layout and paint entirely. It only composites, which means moving or fading an element costs almost nothing because the GPU handles it without touching the main thread. This is why transform: translateX(100px) is vastly cheaper than left: 100px for animations, even though they look the same visually.
JavaScript and the Rendering Pipeline
JavaScript is the biggest wildcard in the rendering pipeline. It can block parsing, trigger reflows, and delay paint.
Parser-blocking scripts
When the HTML parser encounters a <script> tag without async or defer, it stops parsing entirely, fetches the script (if external), and executes it. Only after execution resumes does parsing continue.
This is because scripts can call document.write(), which inserts new HTML into the token stream. The parser cannot safely continue until it knows whether the script modifies the document.
Script loading strategies
| Strategy | Parsing | Execution | Use Case |
|---|---|---|---|
<script> (default) | Blocks parsing until fetched + executed | Immediate, in order | Legacy, avoid if possible |
<script defer> | Does not block parsing | After DOM ready, in order | Most application scripts |
<script async> | Does not block parsing | As soon as downloaded, any order | Analytics, ads, independent widgets |
<script type="module"> | Behaves like defer by default | After DOM ready, in order | ES modules |
Inline <script> | Blocks parsing during execution | Immediate | Critical bootstrap code only |
Critical Rendering Path Optimization
The critical rendering path is the minimum set of work the browser must do before it can paint the first pixel. Optimizing it is the single most impactful thing you can do for perceived load speed.
What blocks first paint
- HTML parsing (cannot skip, but can be streamed)
- Render-blocking CSS (all
<link rel="stylesheet">without media queries) - Parser-blocking JavaScript (any
<script>without async/defer)
The formula is simple: First Paint = HTML download + CSS download + CSS parse. JavaScript extends this if it blocks the parser.
Font loading and rendering
Fonts are a common source of invisible text (FOIT) or layout shifts (FOUT).
Web Vitals: Measuring Real-World Rendering Performance
Google's Core Web Vitals map directly to the rendering pipeline stages:
| Metric | What It Measures | Pipeline Stage | Good Threshold |
|---|---|---|---|
| LCP (Largest Contentful Paint) | When the largest visible element renders | Parse + Style + Layout + Paint | < 2.5s |
| INP (Interaction to Next Paint) | Delay from user input to visual response | JavaScript execution + Layout + Paint | < 200ms |
| CLS (Cumulative Layout Shift) | Unexpected movement of visible elements | Layout recalculations | < 0.1 |
| FCP (First Contentful Paint) | When the first text or image appears | Critical rendering path completion | < 1.8s |
| TTFB (Time to First Byte) | Server response time | Network (pre-pipeline) | < 800ms |
Why CLS happens
Layout shifts occur when the browser completes layout, paints content, and then discovers new information that changes element sizes (late-loading images without dimensions, dynamically injected content, web fonts causing text reflow). The fix is always the same: reserve space before the content arrives. Set width and height on images. Use min-height on dynamic containers. Use font-display: optional for fonts.
What Happens When Things Break
| Failure | What Happens | How to Detect | How to Fix |
|---|---|---|---|
| Render-blocking CSS on slow CDN | White screen until CSS arrives, then all content appears at once | WebPageTest waterfall shows CSS blocking FCP | Inline critical CSS, preload stylesheet |
| Layout thrashing in JS | Janky scrolling, input delays, dropped frames | Performance DevTools shows purple "Layout" bars in flame chart | Batch DOM reads and writes, use requestAnimationFrame |
| Excessive layer promotion | High GPU memory usage, compositing bottleneck | DevTools Layers panel shows 100+ layers | Remove unnecessary will-change, audit transform and opacity on static elements |
| Unoptimized web fonts | Text invisible for 3 seconds (FOIT), then layout shift on swap | CLS reports in Core Web Vitals, Lighthouse audit | font-display: optional + preload |
Synchronous <script> in head | DOM parsing blocked, blank page until all scripts download | WebPageTest waterfall, DOMContentLoaded metric | Add defer to all non-critical scripts |
| Large DOM (10K+ nodes) | Slow layout recalculations, high memory usage, slow selectors | Performance monitor shows layout time > 16ms per frame | Virtualize long lists, simplify DOM depth, lazy-render offscreen content |
| Repeated forced reflows | Reading offsetHeight after mutations in a loop causes multiple layout passes | Performance trace shows multiple "Recalculate Style" + "Layout" pairs | Use ResizeObserver or IntersectionObserver instead of manual measurements |
Performance Characteristics
| Operation | Typical Duration | Triggered By | Can Be Skipped? |
|---|---|---|---|
| HTML parsing | 1-50ms | Initial page load, innerHTML | No |
| CSS parsing | 1-10ms per stylesheet | <link>, <style>, CSSOM modification | No |
| Style recalculation | 1-20ms (DOM size dependent) | Class change, attribute mutation | No (but scope can be reduced) |
| Layout / Reflow | 1-100ms (DOM complexity dependent) | Geometry changes, window resize | Yes (compositor-only changes skip it) |
| Paint | 0.5-50ms | Color changes, shadow updates | Yes (compositor-only changes skip it) |
| Compositing | 0.1-2ms | Transform, opacity changes | Never skipped (always final step) |
| JavaScript execution | 0-unlimited | Script tags, event handlers, timers | Yes (async/defer, code splitting) |
The compositor fast path
Only transform and opacity changes skip both layout and paint and go directly to the compositor. This is the only true "fast path" in the rendering pipeline. Everything else, including color, background, border-radius, and even visibility, triggers at least a paint. Design your animations around transforms and opacity whenever possible.
How This Compares to Alternatives
| Feature | Browser Rendering (DOM) | Canvas 2D | WebGL / WebGPU |
|---|---|---|---|
| Layout model | Automatic (CSS box model) | Manual (you position everything) | Manual (vertex/fragment shaders) |
| Text rendering | Built-in (fonts, wrapping, i18n) | Manual (fillText, no wrapping) | Very manual (texture atlas) |
| Accessibility | Built-in (semantic HTML, ARIA) | None (opaque bitmap) | None (opaque bitmap) |
| Animation performance | Good for transforms/opacity, poor for layout | Good (redraw on requestAnimationFrame) | Excellent (GPU-native) |
| Complexity for UI | Low (HTML + CSS) | Medium (imperative draw calls) | High (shader programming) |
| Best for | Documents, forms, text-heavy UIs | Charts, games, image editing | 3D graphics, data visualization, GPU compute |
I reach for DOM rendering for any application with text, forms, or accessibility requirements. Canvas is the right choice for drawing-heavy interfaces like chart libraries or casual games. WebGL/WebGPU is only justified when you need actual 3D rendering or GPU-compute workloads.
Interview Cheat Sheet
-
When asked about the critical rendering path: "The browser must complete HTML parsing, CSS parsing, and render tree construction before it can paint. CSS is render-blocking, synchronous JS is parser-blocking. The minimum path to first paint is HTML + critical CSS."
-
When asked about
asyncvsdefer: "Both prevent parser blocking.deferexecutes after DOM is ready, in document order.asyncexecutes as soon as downloaded, in any order. Usedeferfor app scripts,asyncfor analytics." -
When asked about layout thrashing: "Reading geometry properties (
offsetWidth,getBoundingClientRect) after DOM mutations forces synchronous reflow. Batch all reads before writes, or userequestAnimationFrameto defer writes to the next frame." -
When asked about compositor layers: "Elements with
transformoropacityanimations get their own GPU layer. Changes to these properties skip layout and paint entirely, going straight to the compositor. This is why CSS transforms are orders of magnitude cheaper thantop/leftanimations." -
When asked about CLS: "Layout shifts happen when elements change size after painting. The fix is reserving space: explicit
width/heighton images,min-heighton dynamic containers, andfont-display: optionalfor fonts." -
When asked about reflow cost: "Layout cost is proportional to DOM size and complexity. A reflow on a 10K-node DOM can take 50-100ms. Reduce DOM depth, use CSS containment (
contain: layout), and avoid triggering layout from JavaScript." -
When asked about rendering large lists: "Virtualize. Only render the visible items plus a small buffer. Libraries like
react-windowor nativecontent-visibility: autoCSS reduce DOM size from thousands of nodes to dozens." -
When asked about paint optimization: "Use the Chrome DevTools Paint Profiler to see exactly which CSS properties trigger repaints.
box-shadow,border-radius, and complex gradients are expensive. Promote animated elements to their own layer withwill-change: transformto isolate their paint cost."
Test Your Understanding
Quick Recap
- The HTML tokenizer converts bytes into tokens, and the tree builder assembles them into the DOM tree using a stack-based algorithm with error correction.
- The CSS parser constructs the CSSOM from all stylesheets, resolving the cascade, specificity, and inheritance to compute final styles for every element.
- The render tree merges the DOM and CSSOM, excluding
display: noneelements but includingvisibility: hiddenandopacity: 0elements. - Layout calculates the exact pixel position and size of every render object using the CSS box model, flexbox, or grid algorithms.
- Paint converts the layout into draw commands (paint records), and compositing sends separate layers to the GPU for rasterization and blending.
- Only
transformandopacityanimations use the compositor fast path, skipping layout and paint entirely. - CSS is render-blocking and JavaScript (without
deferorasync) is parser-blocking, and both extend the critical rendering path. - Core Web Vitals (LCP, INP, CLS) directly measure the efficiency of parse, layout, paint, and compositing stages.
Related Concepts
- How JavaScript engines work: The V8 parsing, compilation, and execution pipeline that determines how script execution time affects rendering.
- HTTP/2 and resource loading: How multiplexing and server push affect the order and speed of resource delivery to the rendering pipeline.
- Content Delivery Networks: How CDN edge caching reduces TTFB and stylesheet delivery latency, directly impacting the critical rendering path.
- Service Workers and caching: How precaching strategies can eliminate network latency for CSS and JavaScript on repeat visits.