Skip to content

Critical Rendering Path

advanced20 min read

From Bytes to Pixels: The Full Pipeline

You click a link. A blank white screen stares back. Then, pixels appear. What just happened in between? Turns out, the browser ran an entire compilation pipeline — and every performance trick you have ever heard of (code splitting, critical CSS, defer, preload) maps directly to one or more stages of it. Understanding this pipeline is not optional. It is the foundation of every performance optimization you will ever make.

Mental Model

The browser is a compiler with a visual backend. It takes a high-level description (HTML + CSS + JS), parses it into intermediate representations (DOM, CSSOM, Render Tree), runs optimization passes (layout, layer promotion), and produces a final output (composited pixels on your GPU). Each stage is a transformation with well-defined inputs and outputs. Performance work means reducing the time and work at each stage — or skipping stages entirely.

Stage 1: Bytes to Characters (Decoding)

Before the browser can do anything useful, it faces a surprisingly basic question: what language are these bytes in? The network delivers raw bytes, and the browser must figure out the character encoding (UTF-8, ISO-8859-1, etc.) to decode them into characters.

48 54 4D 4C → H T M L   (UTF-8 decoding)

Encoding is determined by (in priority order):

  1. HTTP Content-Type header: text/html; charset=utf-8
  2. <meta charset="utf-8"> in the first 1024 bytes
  3. BOM (Byte Order Mark) if present
  4. Browser heuristic sniffing (unreliable, avoid)
Always declare charset

If the browser guesses wrong, it may re-parse the entire document. Always include <meta charset="utf-8"> as the very first element inside <head> — before any other element that could contain non-ASCII characters.

Stage 2: Characters to Tokens (Tokenization)

Now the browser has characters. But characters are just a flat stream of text — the browser needs structure. The HTML tokenizer reads this character stream and produces tokens: start tags, end tags, attributes, and text content. It is a state machine defined in the HTML specification.

Characters:  <div class="card">Hello</div>
Tokens:      StartTag{div, class="card"} → Character{"Hello"} → EndTag{div}

The tokenizer processes characters one at a time. When it encounters <, it enters "tag open" state. When it encounters >, it emits the token. This is incremental — the browser does not wait for the full document to begin tokenization.

Stage 3: Tokens to DOM Nodes (Tree Construction)

Tokens are fed into the tree construction algorithm, which builds the DOM tree. This algorithm handles implicit tag insertion (a <td> outside a <table> creates the table structure), foster parenting, and error recovery.

Document
└── html
    ├── head
    │   ├── meta[charset="utf-8"]
    │   └── link[rel="stylesheet", href="styles.css"]
    └── body
        └── div.card
            └── "Hello"

The DOM is not a string. It is a tree of C++ objects (in Blink/V8) with properties, methods, and event listeners. Every DOM node has a corresponding internal representation in the rendering engine.

Stage 4: CSS to CSSOM (Style Computation)

Here is where it gets interesting. While the DOM is being built, the browser is simultaneously parsing CSS into the CSSOM (CSS Object Model). The CSSOM represents the computed styles for every element, after cascading, specificity resolution, and inheritance.

body { font-size: 16px; color: #333; }
.card { padding: 1rem; background: white; }

CSSOM Node: div.card
  → font-size: 16px (inherited from body)
  → color: #333 (inherited from body)
  → padding: 16px (own rule, rem resolved)
  → background: white (own rule)
  → display: block (user agent default)
  → ... hundreds more computed properties

Here is the thing most people miss: every element has a fully resolved set of computed styles — there is no "unset" in the final CSSOM. Every CSS property has a value, even if it is the initial/inherited default.

Quiz
An element has no explicit styles. How many computed CSS properties does it have in the CSSOM?

Stage 5: DOM + CSSOM = Render Tree

Now the browser merges its two data structures. It walks the DOM and, for each visible node, attaches the computed styles from the CSSOM. Elements with display: none? Gone. Excluded entirely. The result is the Render Tree — only the nodes that will actually produce visual output.

Render Tree (only visible nodes):
├── RenderBlock{html}
│   └── RenderBlock{body}
│       └── RenderBlock{div.card}  [padding:16px, bg:white]
│           └── RenderText{"Hello"}  [font-size:16px, color:#333]

Excluded: <head>, <meta>, <script>, elements with display:none
Common Trap

visibility: hidden elements ARE in the render tree — they occupy space and participate in layout, they just don't paint pixels. Only display: none removes an element from the render tree entirely. This distinction matters for layout calculations and performance — a visibility: hidden element still triggers layout work.

Stage 6: Layout (Reflow)

The browser traverses the render tree and calculates the exact position and size of every element. This is a recursive process — a parent's size may depend on its children (auto height), and a child's size may depend on its parent (percentage width).

Layout output for div.card:
  x: 8px, y: 8px (body margin)
  width: 784px (viewport 800px - body margin 16px)
  height: 52px (16px padding-top + 20px line-height + 16px padding-bottom)

Layout is one of the most expensive stages. It scales with the number of elements in the render tree and the complexity of their relationships (flexbox/grid layouts are more expensive than block layout but produce better results).

Quiz
A CSS change sets color: red on a div. Which pipeline stages must the browser re-execute?

Stage 7: Paint

The browser converts each render tree node into actual drawing commands: fill rectangles, draw text, render shadows, clip regions. Paint produces a display list — an ordered sequence of draw operations.

Paint commands for div.card:
  1. drawRect(8, 8, 784, 52, fill: white)      → background
  2. drawText("Hello", 24, 36, font: 16px, color: #333)  → text content

Paint is per-layer. If an element is on its own compositing layer (due to will-change, transform, opacity, etc.), it is painted independently. Changes to that element only repaint that layer, not the entire page.

Stage 8: Composite

The compositor takes all painted layers, applies transforms and opacity, determines their stacking order, and composites them into the final image. This stage runs on the compositor thread (separate from the main thread) and leverages the GPU.

Layer 1: Background (root layer) → GPU texture
Layer 2: div.card (promoted layer) → GPU texture
Final: Composite Layer 1 + Layer 2 → Screen

Compositor-only changes (transform, opacity) are extremely fast because they skip layout and paint entirely — the compositor just moves or fades existing GPU textures.

The Complete Pipeline as ExecutionTrace

Execution Trace
Network
Raw bytes arrive from server via TCP/TLS
Chunked transfer — processing starts before full download
Decode
Bytes → Characters (UTF-8 decoding)
Encoding from Content-Type header or `<meta charset>`
Tokenize
Characters → Tokens (HTML state machine)
Incremental — tokens emitted as characters are consumed
Parse
Tokens → DOM Tree (tree construction algorithm)
Handles implicit elements, error recovery, foster parenting
Style
CSS parsed → CSSOM built → Styles computed per element
Cascade, specificity, inheritance all resolved
Render Tree
DOM + CSSOM → Render Tree (visible nodes only)
display:none excluded, visibility:hidden included
Layout
Render Tree → Box geometry (position + size for every element)
Recursive — parent/child size dependencies resolved
Paint
Render Tree → Display list (drawing commands per layer)
Fill rects, draw text, clip, shadow — ordered operations
Composite
Layers assembled, transformed, sent to GPU → Pixels on screen
Runs on compositor thread, leverages GPU rasterization

What Blocks Rendering

This is the part that burns people in production. Not all resources are equal — some block the pipeline, and some don't:

ResourceBlocks Parsing?Blocks Rendering?Why
CSS <link> in <head>NoYesCSSOM must complete before render tree
<script> (no attr)YesYesParser stops, waits for download + execute
<script defer>NoNoDownloads in parallel, executes after parse
<script async>NoBrieflyDownloads in parallel, blocks when executing
ImagesNoNoLoaded asynchronously, paint when ready
FontsNoPartiallyText invisible (FOIT) or fallback (FOUT) until loaded
Quiz
A page has one CSS file (80KB), one defer script (200KB), and one async script (50KB). The CSS file takes 300ms to download. What is the minimum time before the browser can paint the first pixel?

Optimizing the Critical Path

So how do you actually make this faster? You have three levers:

  1. Reduce critical resources — Fewer render-blocking files means fewer round trips before first paint
  2. Reduce critical path length — Shorten the chain of sequential, dependent requests
  3. Reduce critical bytes — Smaller files download faster

Inline Critical CSS

Extract styles needed for above-the-fold content and inline them directly in the HTML. Load the full stylesheet asynchronously.

<head>
  <!-- Critical styles inlined — no network request needed -->
  <style>
    body { margin: 0; font-family: system-ui; }
    .hero { padding: 4rem 2rem; background: #0a0a0a; color: white; }
    .hero h1 { font-size: 3rem; }
  </style>

  <!-- Full stylesheet loaded asynchronously -->
  <link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>

Resource Hints

<!-- Preconnect: establish connection early (DNS + TCP + TLS) -->
<link rel="preconnect" href="https://fonts.googleapis.com">

<!-- Preload: fetch this resource with high priority, I'll need it soon -->
<link rel="preload" href="/critical-font.woff2" as="font" type="font/woff2" crossorigin>

<!-- Prefetch: fetch this resource at low priority, I might need it on the next page -->
<link rel="prefetch" href="/next-page-bundle.js">
HTTP 103 Early Hints

The server can send a 103 Early Hints response before the full 200 response is ready. This tells the browser to start preloading resources while the server is still generating the page. This is particularly valuable for server-rendered pages where the server needs time to query databases or render templates — the browser can begin fetching CSS and fonts during that wait time, shaving hundreds of milliseconds off the critical path.

Quiz
You have a Next.js app where the largest CSS file is 120KB. First paint takes 1.8s on 3G. Which optimization has the highest impact?
Key Rules
  1. 1The critical rendering path has 8 stages: Network → Decode → Tokenize → Parse → Style → Render Tree → Layout → Paint → Composite.
  2. 2CSS is render-blocking. The browser will not paint until CSSOM is fully constructed.
  3. 3JavaScript is parser-blocking (without async/defer). The HTML parser halts at synchronous scripts.
  4. 4display:none removes elements from the render tree. visibility:hidden keeps them (they still cause layout work).
  5. 5Changing color skips layout (paint + composite). Changing width triggers layout + paint + composite. Changing transform triggers only composite.
  6. 6Optimize by reducing critical resources (fewer blocking files), critical path length (fewer sequential requests), and critical bytes (smaller files).
  7. 7Inline critical CSS and async-load the rest. Use defer for scripts. Use preload/preconnect for critical resources.
Interview Question

Q: Walk me through what happens from the moment a browser receives the first byte of an HTML response to the first pixel painted on screen.

A strong answer covers: byte decoding (charset), tokenization (HTML state machine), DOM construction (tree builder, error recovery), CSSOM construction (parallel to DOM, render-blocking), render tree (merge DOM + CSSOM, exclude display:none), layout (recursive geometry calculation), paint (display list generation per layer), and composite (GPU layer assembly). Bonus: mention speculative parsing (preload scanner), the CSSOM-JS dependency chain, and how defer/async scripts interact with the pipeline.

1/12