The Browser Rendering Pipeline: From URL to Pixels

· browserrenderingperformancefrontend

The Big Picture

Think of the browser as a factory with a six-stage assembly line. Raw materials go in one end — HTML, CSS, JavaScript — and pixels come out the other. Each stage transforms the product into something closer to the final display.

The six stages are: DOM construction from HTML, CSSOM construction from CSS, merging both into the Render Tree, computing element geometry in Layout, filling pixels in Paint, and stacking layers together in Composite.

Every stage depends on the stage before it. But here is the key insight for performance: some CSS changes skip stages entirely. Changing background-color triggers Paint but skips Layout. Changing transform or opacity skips straight to Composite. Understanding which stage each operation touches is how we write fast, smooth web pages.

HTML Parsing

The browser receives HTML as raw bytes over the network. The first job is to turn those bytes into a tree of objects the browser can work with — the Document Object Model or DOM.

The process runs through four phases:

  1. Bytes to characters — the browser decodes the byte stream using the file’s encoding (usually UTF-8).
  2. Characters to tokens — the HTML tokenizer scans character by character and emits tokens: start tags, end tags, attribute names, attribute values, text content.
  3. Tokens to nodes — the tree construction algorithm consumes tokens and creates Node objects (Element, Text, Comment, etc.).
  4. Nodes to DOM tree — nodes are wired into a tree structure. A <div> becomes an Element node, its child <p> becomes a child Element node, and so on.

The DOM is not the HTML you wrote. It is the browser’s internal representation — corrected for errors, missing tags, and misnesting. The browser never shows you the HTML source; it renders from the DOM.

HTML Parsing
0 / 12 tokens
HTML Source
<html>
<body>
<div>
<h1>Title</h1>
<p>Text</p>
</div>
</body>
</html>
DOM Tree
Parsing not started...

CSS Parsing

While HTML is being parsed into the DOM, the browser also processes CSS. Every stylesheet — external files, <style> blocks, and inline style attributes — goes through its own parsing pipeline.

CSS bytes become tokens, tokens become rules, and rules are organized into the CSS Object Model or CSSOM. The CSSOM is a tree structure, but unlike the DOM it is optimized for cascading — walking from general rules to specific ones.

During CSSOM construction the browser resolves the cascade: for each property of each element, which rule wins? It applies this priority order:

  • Importance!important beats normal declarations
  • Origin — author styles beat user-agent defaults
  • Specificity — inline > ID > class > element
  • Source order — last declaration wins if specificity is tied

If an external CSS file is still downloading, the browser blocks rendering — it refuses to paint anything until the CSSOM is ready. This is why CSS is called a “render-blocking” resource.

CSS Parsing

CSS source text is parsed into a CSSOM tree of rules. Each rule has a selector, properties, and a specificity score. The cascade resolves conflicts when multiple rules target the same property.

4 rules parsed
CSS Source
/* element selector */ p { color: blue; font-size: 16px; } /* class selector */ .highlight { color: green; font-weight: bold; } /* id selector */ #main-title { color: red; font-size: 24px; text-align: center; } /* element + class */ p.highlight { background: yellow; color: purple; }
CSSOM Tree
StyleSheet
element/* element selector */p(0,0,1,0)
compound/* class selector */.highlight(0,1,1,0)
compound/* id selector */#main-title(1,0,1,0)
compound/* element + class */p.highlight(0,1,1,0)

The Render Tree

The DOM and CSSOM are independent trees. The browser merges them into a third structure: the Render Tree. This tree contains only the nodes that will actually appear on screen.

The merge process walks every DOM node and checks its CSSOM entry. If the node has display: none, or is a non-visual element like <script> or <meta>, it is excluded from the render tree. If the node has visibility: hidden, it stays — hidden elements still occupy space and affect layout.

Each surviving node becomes a render object with a reference to its DOM node and its computed styles. The render tree is what Layout operates on.

Render Tree Construction

The render tree combines DOM and CSSOM. Nodes with display: noneare excluded. Pseudo-elements like ::before and ::after are included.

DOM Tree
<html>
<head>
│ │ <title>
│ │ │ "My Page"
<body>
│ │ <header>
│ │ │ "Welcome!"
│ │ <nav>hidden
│ │ │ "Navigation links"
│ │ <main>
│ │ │ <article>
│ │ │ │ <h1>
│ │ │ │ │ "Hello World"
│ │ │ │ <p>
│ │ │ │ │ "This is content."
│ │ <footer>
│ │ │ "Footer info"
│ │ │ ::before
│ │ │ ::after
CSSOM
body
font:16px sans-serif
color:#333
margin:0
header
font:bold 24px sans-serif
color:#111
padding:20px
background:#f0f0f0
nav
display:none
background:#eee
main
font:16px sans-serif
color:#222
padding:20px
lineHeight:1.6
article
font:16px sans-serif
color:#222
maxWidth:800px
margin:0 auto
h1
font:bold 28px sans-serif
color:#000
marginBottom:10px
p
font:16px sans-serif
color:#444
marginBottom:10px
footer
font:14px sans-serif
color:#666
padding:10px
borderTop:1px solid #ccc
position:relative
footer::before
content:""
display:block
height:2px
background:#999
footer::after
content:"---end---"
display:block
textAlign:center
color:#aaa
Render Tree (renderable only)
<html>
<head>
│ │ <title>
│ │ │ "My Page"
<body>
│ │ <header>
│ │ │ "Welcome!"
│ │ │ "Navigation links"
│ │ <main>
│ │ │ <article>
│ │ │ │ <h1>
│ │ │ │ │ "Hello World"
│ │ │ │ <p>
│ │ │ │ │ "This is content."
│ │ <footer>
│ │ │ "Footer info"
│ │ │ ::beforepseudo-element
│ │ │ ::afterpseudo-element

Layout (Reflow)

With the render tree built, the browser now needs to figure out where everything goes. This stage is called Layout, also known as Reflow.

The browser starts at the root of the render tree and computes geometry for every node: x and y coordinates, width, height, margin, padding, border. This is where the box model becomes concrete — box-sizing, margin collapsing, percentage widths all resolve to actual pixel values here.

Layout flows top-down and left-to-right. A parent’s width affects its children’s widths. A child’s height can affect the parent’s height (unless the parent has an explicit height). The result is a layout tree where every element knows its exact position and size in viewport coordinates.

Layout is expensive. Changing width of a top-level element can cause the browser to relayout every descendant and potentially every sibling. The time is roughly proportional to the number of render objects and the depth of the tree.

Layout (Reflow)

The browser calculates geometric positions for every render tree node. Drag the slider to change viewport width and watch the layout recalculate.

Layout stable
Reflows: 0
Header708x48Sidebar160x260Main Content540x260Footer708x36
Box Model Details
Header
margin
border
content
x: 16 y: 16 w: 708 h: 48
margin: 0,0,0,0
padding: 12,16,12,16
Sidebar
margin
border
content
x: 16 y: 72 w: 160 h: 260
margin: 0,8,0,0
padding: 10,12,10,12
Main Content
margin
border
content
x: 184 y: 72 w: 540 h: 260
margin: 0,0,0,8
padding: 10,16,10,16
Footer
margin
border
content
x: 16 y: 348 w: 708 h: 36
margin: 8,0,0,0
padding: 8,16,8,16

Paint

Once the browser knows where every element is, it draws them onto pixels. This is the Paint stage.

Painting happens in a specific order, sometimes called the painting order or stacking context order:

  1. Background and borders of the element itself
  2. Negative z-index child stacking contexts
  3. Block-level descendants in normal flow
  4. Floating descendants
  5. Inline-level descendants (text, images)
  6. Non-positioned child stacking contexts
  7. Positioned child stacking contexts (by z-index)

Elements are painted onto one or more bitmaps (called layers). Each layer is a separate surface that the browser can repaint independently. If only one element on the page changes color that is tracked in a separate paint layer (e.g., via will-change), the browser repaints only that layer, not the entire page.

Paint is rasterization — turning vector data (shapes, text, gradients) into pixels. The browser’s rasterizer walks each render object and fills pixels according to its computed styles. Text uses the font rasterizer. Borders use shape rasterizers. Shadows are blurred and composited.

Paint Step Visualization

The paint step fills in pixels for each visual property of an element. Properties are painted in a specific order. Step through to see each layer.

Hello Browser
1. Draw box-shadow
2. Draw background
3. Draw border
4. Draw content
Click a paint step above or use the buttons below.
Stacking Context

Elements with different position values or z-index create stacking contexts. Paint order follows this stacking hierarchy.

z-index: 1
z-index: 2
z-index: 3
Stacking order determines
which element paints on top

Compositing

After individual layers are painted, the browser merges them together in the Composite stage. This is like stacking transparent sheets of plastic — each layer has its own content, and the layers are combined in a specific order with blending and alpha.

Compositing is handled by the GPU in most modern browsers. Each layer is uploaded to GPU memory as a texture. The GPU can move, scale, rotate, and fade layers without involving the CPU — and without triggering Layout or Paint on the rest of the page.

This is why transform: translateX(100px) is faster than left: 100px. The transform only touches compositing. Changing left triggers Layout, then Paint, then Composite. For animations, using transform and opacity means the browser only needs to recomposite each frame — staying within the 16.67ms16.67\text{ms} budget is far easier.

The browser automatically creates layers for certain conditions: elements with <video> or <canvas>, elements with will-change, elements with 3D transforms (translateZ(0)), and elements that overlap with composited siblings.

Compositing Layers

Certain properties promote elements to their own compositing layer. Layers are painted independently and then composited together by the GPU.

Page BackgroundGPU
Fixed HeaderGPU
Animated CardGPU
Video ElementGPU
Layer Properties
x
Default
x
position: fixed
x
will-change: transform
x
<video>
Default (no promotion)
0%
Compositing Info
Active layers: 4
GPU composited: 3
Click a layer to isolate it
Idle

JavaScript Blocking

JavaScript adds a complication to the pipeline. When the HTML parser encounters a <script> tag without async or defer, it pauses DOM construction entirely. It stops parsing HTML, fetches the script (if external), executes it, and only then resumes building the DOM.

Why does it block? Because JavaScript can modify the DOM and CSSOM. A script might use document.write() to insert new HTML, or read element.style.color which requires the CSSOM to be ready. The browser cannot safely continue parsing while a script could change the structure.

This blocking behavior is the cause of most slow initial page loads. Every parser-blocking script adds its fetch time + execute time to the critical rendering path. The fix is async and defer:

  • <script async src="app.js"> — downloads in parallel with parsing. Executes as soon as it finishes downloading, regardless of where the parser is.
  • <script defer src="app.js"> — downloads in parallel with parsing. Executes after parsing completes, in document order.

Use async for independent scripts that don’t touch the DOM (analytics, ads). Use defer for scripts that need the full DOM to be ready (your application code).

Async vs Defer
Normal <script>DCL: 420ms | Load: 500ms
0ms
200ms
400ms
<script async>DCL: 280ms | Load: 500ms
0ms
200ms
400ms
<script defer>FASTESTDCL: 130ms | Load: 500ms
0ms
200ms
400ms
How It Works
Normal: HTML parsing pauses. Script loads and executes synchronously. DOMContentLoaded waits for all scripts.
Async: Script loads in parallel. Executes as soon as ready. May interrupt parsing. DCL waits for async scripts.
Defer: Script loads in parallel. Executes after parsing. Guarantees order. DCL fires after deferred scripts.

Reflow Triggers

Layout (reflow) is triggered whenever the browser detects that the geometry of an element has changed or might have changed. Some triggers are obvious, others are subtle.

Obvious triggers — changing a CSS property that affects geometry:

element.style.width = '200px';
element.style.marginTop = '20px';
element.style.display = 'none';
element.classList.add('new-layout-class');

Subtle triggers — reading layout properties forces a synchronous reflow. The browser must compute layout to give you an accurate value:

// Every one of these forces a reflow if layout is dirty
const h = element.offsetHeight;
const w = element.clientWidth;
const top = element.getBoundingClientRect().top;
const scrollPos = element.scrollTop;
const isVisible = element.offsetParent !== null;
window.getComputedStyle(element).top;

When you modify a style and then immediately read a layout property, the browser has no choice — it must run Layout right there, synchronously, before returning the value. This is called a forced synchronous layout.

Reflow Triggers

Writes are deferred. Only reads and the final flush trigger reflow.

0
Total Reflows
0
Pending Writes
Batched
Mode
Timeline
No reflows yet. Click an operation above.
How Batching Works
Unbatched Pattern
div.style.width = '100px'
console.log(div.offsetWidth) // FORCED REFLOW
div.style.height = '50px'
console.log(div.offsetHeight) // FORCED REFLOW
Batched Pattern
div.style.width = '100px'
div.style.height = '50px'
console.log(div.offsetHeight) // ONE reflow total

Layout Thrashing

Layout thrashing happens when forced synchronous layouts are triggered repeatedly in a loop. Each iteration forces the browser to recalculate layout, then invalidates it again, creating a costly read-write cycle.

Here is the classic anti-pattern:

// Unbatched: 100 forced reflows
for (let i = 0; i < items.length; i++) {
  const h = items[i].offsetHeight;          // read  → reflow
  items[i].style.height = (h + 10) + 'px';  // write → invalidates layout
}

Each iteration reads the current height (forcing a reflow), then writes a new height (invalidating layout). The next iteration reads again, forcing another reflow. One hundred iterations, one hundred reflows.

The fix is batching — separate all reads from all writes:

// Batched: 1 reflow (batch read), then 1 reflow (batch write)
const heights = [];
for (let i = 0; i < items.length; i++) {
  heights.push(items[i].offsetHeight); // read phase — one reflow
}
for (let i = 0; i < items.length; i++) {
  items[i].style.height = (heights[i] + 10) + 'px'; // write phase — one reflow
}

Layout thrashing is invisible to the developer — the code “works” — but causes visible jank for the user. The frame budget is eaten by repeated layout calculations instead of painting.

Layout Thrashing Demo

Interleaving DOM reads and writes forces the browser to recalculate layout after every write. Batching avoids this.

Reflow Count
Unbatched
0
Batched
0
Frame Time (simulated)
Unbatched
0ms
Batched
0ms
Operation Log
Click a run button to start.
Unbatched
// Unbatched: read-write-read-write thrashing
for (let i = 0; i < 5; i++) {
  const w = el.offsetWidth;  // READ
  el.style.width = w + 10 + 'px'; // WRITE
}
// Result: 5 forced reflows
Batched
// Batched: reads then writes
const widths = [];
for (let i = 0; i < 5; i++) {
  widths.push(el.offsetWidth); // READS
}
for (let i = 0; i < 5; i++) {
  el.style.width =
    widths[i] + 10 + 'px'; // WRITES
}
// Result: 1 reflow total

Layer Promotion

We learned that compositing-only changes are the cheapest. The key to unlocking compositor-only updates is layer promotion — telling the browser to put an element on its own compositor layer.

The most common techniques:

/* Hint that the element should be a layer */
.element {
  will-change: transform;
}

/* Legacy hack — still works everywhere */
.element {
  transform: translateZ(0);
}

/* Paint containment — new, clean approach */
.element {
  contain: paint;
}

will-change: transform is the declarative approach. It tells the browser “this element will animate, prepare a layer for it.” The browser can pre-allocate GPU memory and skip Layout and Paint during the animation.

transform: translateZ(0) is the old hack. It forces a 3D transform (z-axis translation of zero), which triggers layer creation in every browser. It works, but it’s wasteful — every promoted layer uses GPU memory. Promoting hundreds of elements can exhaust GPU memory on mobile devices.

contain: paint creates a new stacking context and clips the element’s paint to its box. The element can be repainted independently without affecting siblings, but it does not guarantee GPU compositing.

The rule: promote layers deliberately. Profile first, then promote only the elements that benefit — usually animated elements that move or fade every frame.

Will Change & Compositor Layers
will-change
Animation Property
0px300px
Frame Timing
10ms / frame
100 FPS
Layer
Normal Flow Layer
Animated Box (in flow)
Diagnosis
Transform avoids layout but no dedicated layer means paint still happens on the main thread.

The Critical Rendering Path

The Critical Rendering Path (CRP) is the sequence of steps the browser must complete before it can paint the first frame. Every resource on this path directly delays the user seeing anything.

For the initial page load, the CRP is:

  1. HTML starts arriving. Parsing begins immediately.
  2. CSS <link> in <head> is discovered. Download begins. Rendering is blocked until CSSOM is ready.
  3. Scripts without async/defer are discovered. Parsing is blocked during fetch and execution.
  4. Once DOM and CSSOM are ready, Render Tree is built, Layout runs, Paint runs, Composite runs.
  5. First paint happens.

Each render-blocking resource adds its round-trip time to the CRP. Measuring and optimizing the CRP means:

  • Inline critical CSS in <head> so the first paint needs no external CSS download
  • Defer non-critical CSS with media="print" or rel="preload" with onload
  • Use async or defer on all scripts
  • Remove unused CSS to reduce CSSOM size
  • Compress and minify everything on the path

The goal is a first paint in under one second on a 3G connection. That means the total CRP length (all round-trips plus processing time) must fit under 1000ms.

Critical Rendering Path
CSS
Script
Preload
0ms
50ms
100ms
150ms
200ms
250ms
300ms
350ms
400ms
450ms
500ms
HTML
CSS
JS (blocking)
First Paint: 360ms
Time to First Paint: 360ms
render-blockingnon-blocking

A Single Frame

When the page is running at 60 frames per second, each frame has exactly 16.67ms16.67\text{ms} to complete. The browser follows a fixed pipeline for every frame:

JavaScript → Style → Layout → Paint → Composite

The frame starts with a vsync signal from the display. The browser’s rendering engine runs its scheduled tasks:

  1. JavaScript — executes requestAnimationFrame callbacks, resolves promises, processes DOM events. Should take no more than 5-8ms.
  2. Style — recalculates computed styles for any elements with changed or inherited properties.
  3. Layout — recomputes geometry for any elements affected by style changes.
  4. Paint — rasterizes changed regions.
  5. Composite — merges layers for display.

If the total exceeds 16.67ms16.67\text{ms}, the frame is dropped — the user sees a stutter instead of smooth motion. Chrome DevTools’ Performance panel shows this as a red bar in the frame overview.

The 60fps target is a hard deadline. Tasks that cannot fit in the frame budget (like network responses) should be deferred to the next idle period via requestIdleCallback or a setTimeout with zero delay.

Frame Pipeline
Workload
Simple update with minimal changes.
0msBudget: 16ms16ms
JS2ms
Style
Layout
Paint2ms
Composite
Total Time
7ms
Actual FPS
60
Smooth
Bottleneck
JS
2ms (longest stage)
JS
2ms (bottleneck)
Style
1ms
Layout
1ms
Paint
2ms
Composite
1ms

Self-Check

Test your understanding by working through these exercises.

Identify the Reflow Triggers

Look at each code snippet. Does it force a reflow?

// Snippet A
element.style.padding = '20px';
console.log(element.offsetHeight);
// Snippet B
element.style.transform = 'translateX(100px)';
element.style.opacity = '0.5';
// Snippet C
element.classList.add('hidden');
element.style.width = '50%';
const rect = element.getBoundingClientRect();

Answers: A forces reflow (write + read). B does not — transform and opacity only trigger composite. C forces two reflows: the classList.add and style.width dirty layout, then getBoundingClientRect() forces synchronous reflow.

Optimize the Critical Path

Given this HTML head, describe three optimizations:

<head>
  <link rel="stylesheet" href="styles.css">
  <link rel="stylesheet" href="fonts.css">
  <script src="analytics.js"></script>
  <script src="app.js"></script>
</head>

Optimizations: (1) Inline critical CSS from styles.css into a <style> block. (2) Load fonts.css with <link rel="preload" as="style" href="fonts.css" onload="this.rel='stylesheet'"> to avoid blocking. (3) Add async to analytics.js and defer to app.js.

Compare Layer Strategies

StrategyCreates LayerGPU MemoryUse Case
will-change: transformYesAllocated lazilyAnimations you know about
transform: translateZ(0)YesImmediateLegacy fallback
contain: paintPaint isolation onlyMinimalIsolated repaints
opacity: 0.99In some browsersVariableSide effect, avoid

Performance Checklist

  • I know which CSS properties trigger Layout, Paint, or only Composite
  • My animations use transform and opacity exclusively
  • I batch DOM reads separately from DOM writes
  • I load scripts with async or defer
  • I inline critical CSS for first paint
  • I promote layers deliberately, not with blanket translateZ(0)
  • I profile frames in DevTools before optimizing

Understanding the rendering pipeline turns performance optimization from guesswork into engineering. When you know exactly which stage a change touches, you can predict its cost — and decide deliberately which costs are worth paying.