You type a URL, hit enter. 50ms later, pixels appear. What actually happened? The browser, like Vishwakarma, constructs an entire universe from formless bytes — and it does it in stages.
— The Pipeline, every page loadIn Hindu mythology, Vishwakarma — the divine architect and craftsman of the gods — does not conjure the universe in a single instant. He works through stages: conceiving the form, establishing the laws of proportion, laying out structure, then adding colour and luminance. The browser's rendering pipeline is a perfect technological parallel. HTML arrives as undifferentiated bytes. The pipeline imposes structure, law, geometry, paint, and finally composites it all into the world you see. Each stage has its own dharma. Skip one or disrupt it and the entire manifestation breaks.
Chapter 01 — The Six Stages
The Pipeline at a Glance
Every frame your browser renders passes through up to six sequential stages.
A change to width restarts from Layout. A change to color
restarts from Paint. A change to transform runs only the final stage —
Composite. Understanding this is the foundation of all browser performance work.
Chapter 02 — Deep Dives
Each Stage, Explained
Let's walk through each stage in detail — what the browser actually does, what triggers it, and what the mythological parallel reveals about its nature.
HTML Parsing → DOM
The universe begins as formless sound — Nada Brahma, the primordial vibration. The parser is the force that gives structure to those undifferentiated bytes, weaving them into a tree of meaning.
When the browser receives HTML bytes from the network, it doesn't see HTML — it sees
a stream of bytes. The first job is to convert those bytes into characters
(via the charset declared in the HTTP header or <meta charset>), then
into tokens (start tags, end tags, attribute values, text nodes), and
finally into nodes arranged in the Document Object Model (DOM) tree.
Parsing is incremental. The browser doesn't wait for the full HTML to arrive before beginning to parse. It parses in chunks as bytes arrive over the network — this is why you sometimes see partial page renders on slow connections. The tokenizer runs as a state machine, and the tree builder takes those tokens and constructs parent-child relationships.
The critical performance landmine here is the parser-blocking script.
When the parser encounters a <script> tag without defer
or async, it stops — completely — until that script downloads, parses,
and executes. This is because scripts may call document.write() and
fundamentally alter the HTML stream. The browser cannot know in advance, so it stops.
This is why defer is almost always the right choice.
<!-- ❌ Parser-blocking: halts HTML parsing until script runs --> <script src="heavy.js"></script> <!-- ✅ Deferred: parses in parallel, runs after DOM is ready --> <script src="heavy.js" defer></script> <!-- ✅ Async: fetches in parallel, runs as soon as available --> <script src="analytics.js" async></script>
CSS Parsing → CSSOM
Dharma — the laws of nature. Invisible rules that govern how everything manifests. You cannot see gravity, yet it shapes every physical form. The CSSOM is the browser's dharma: unseen laws that determine the appearance of every node.
While the HTML parser builds the DOM, a parallel process handles CSS. Every stylesheet —
whether inline <style> or external <link> — is parsed
into the CSS Object Model (CSSOM): a tree of style rules indexed by selector.
Unlike HTML parsing, CSS parsing is not incremental. The browser needs the entire stylesheet before it can apply it, because later rules can override earlier ones. A rule at the bottom of your CSS can change the appearance of elements at the top. This makes CSS a render-blocking resource: the browser will not produce a rendered frame until all stylesheets have been fully downloaded and parsed.
Cascade and specificity are resolved during CSSOM construction. The browser computes which rules win for each selector by comparing specificity (inline styles beat IDs beat classes beat elements) and origin (author vs user-agent). The result is a complete map: "for this selector, these are the winning computed values."
The performance implication is stark: a stylesheet loaded late holds up
the entire render. Place critical CSS in the <head>, use media
attributes to skip non-applicable stylesheets, or inline critical CSS for zero render-blocking cost.
<!-- ❌ Render-blocking: browser won't paint until this loads --> <link rel="stylesheet" href="styles.css"> <!-- ✅ Non-blocking for print: won't block initial render --> <link rel="stylesheet" href="print.css" media="print"> <!-- ✅ Async CSS pattern (non-blocking) --> <link rel="preload" as="style" href="non-critical.css" onload="this.rel='stylesheet'">
Render Tree
Maya — the veil of illusion. Not everything that exists is visible; not everything visible truly exists. The render tree is what actually manifests — the subset of the DOM that will be perceived, stripped of the unseen.
The Render Tree is constructed by combining the DOM and CSSOM. It contains only the nodes that will be rendered visually — hidden nodes are excluded. This is not the same as the DOM tree.
The rules of inclusion are precise:
display: none removes the node entirely — it exists in
the DOM but has no box in the render tree, occupies no space, triggers no geometry.
visibility: hidden keeps the node in the render tree
and preserves its space, but paints it as invisible.
opacity: 0 keeps everything including interactivity —
the element is fully present, just transparent.
The browser also inserts anonymous boxes — nodes not in the DOM that
the layout engine needs for internal bookkeeping. When you wrap text directly inside a
<div> alongside block children, the browser creates anonymous block boxes
to normalize the tree. These are invisible to JavaScript but real to the rendering engine.
/* display:none — removed from render tree entirely */ .hidden { display: none; } /* no box, no geometry */ /* visibility:hidden — in render tree, painted invisible */ .invisible { visibility: hidden; } /* space preserved */ /* opacity:0 — fully composited, still interactive */ .transparent { opacity: 0; } /* cheapest for animation */
opacity: 0 avoids reflow and repaint when animated.
display: none toggling is the most expensive — it triggers a full render tree rebuild,
reflow, and repaint. Prefer visibility or opacity for hiding animated elements.
Layout (Reflow)
Karma in action — every DOM change ripples consequences through the entire pipeline. Like karma, layout changes propagate: move one element and every dependent element must recalculate its position. The universe of geometry must rebalance.
Layout (also called reflow) is where geometry is computed.
The browser takes the render tree and calculates the exact position and size of every node —
in device pixels, relative to the viewport. This means resolving percentages, computing
auto margins, handling flex and grid algorithms,
and respecting the CSS box model.
Layout is expensive because it's inherently sequential: a parent's size
depends on its children; children's positions depend on their parent. Changes propagate.
When you change width, the browser must recompute the width, then all children
that depend on that width, then their children — a cascading recalculation.
Layout thrashing is the worst-case scenario: reading a layout property
(like offsetHeight, getBoundingClientRect()) forces the browser
to flush all pending style changes and compute layout synchronously. If you then modify
layout and read again in a loop, you force a layout recalculation on every single iteration.
This is one of the most common causes of janky animations.
// ❌ Layout thrashing — forces reflow in a loop const items = document.querySelectorAll('.item'); items.forEach(item => { const h = item.offsetHeight; // read → forces layout flush item.style.height = (h * 2) + 'px'; // write → invalidates layout }); // ✅ Batch reads, then batch writes const heights = [...items].map(el => el.offsetHeight); // read phase items.forEach((item, i) => { item.style.height = (heights[i] * 2) + 'px'; // write phase });
Paint
The world taking colour and form. After Vishwakarma establishes proportion and structure, the painters arrive — filling in the hues, the textures, the gradients of manifest reality.
Once layout has computed where everything goes, Paint determines what it looks like. The browser creates display lists — ordered records of paint operations (fill a rectangle with this colour, draw this text at this position, clip to this region). These are not pixels yet; they are instructions for producing pixels.
Rasterization is the process of executing those instructions to produce actual pixels. Chrome uses Skia, a cross-platform 2D graphics library, for rasterization. On modern hardware, rasterization happens on the GPU: display lists are uploaded as GPU commands and executed in parallel.
The browser creates paint layers (not to be confused with compositor layers).
Elements that might need independent repainting — like elements with z-index,
positioned elements, or elements with certain CSS properties — may get their own paint layer.
This allows the browser to repaint only the affected area rather than the entire page.
Crucially, paint can happen without layout. If you change color
or background-color, the browser knows geometry has not changed — it can skip
layout entirely and jump straight to repaint.
/* These trigger PAINT ONLY — no layout recalculation */ .box { color: #fff; /* text colour → repaint */ background-color: rgba(0,0,0,0.5); /* bg → repaint */ box-shadow: 0 4px 12px rgba(0,0,0,0.4); /* shadow → repaint */ border-color: #3b82f6; /* border colour → repaint */ outline: 2px solid blue; /* outline → repaint */ } /* But these trigger LAYOUT + PAINT (size/position changed) */ .box--bad { border-width: 4px; /* changes box model → reflow! */ padding: 20px; /* changes geometry → reflow! */ }
Composite
Moksha — liberation. The compositor thread runs free from the main thread, unburdened by JavaScript or layout calculations. It operates in a state of pure, unobstructed flow, assembling the final frame on the GPU.
The final stage assembles the painted layers into the final frame displayed on screen. The compositor thread takes the rasterized layer tiles and composites them together — handling scrolling, transforms, and opacity adjustments entirely on the GPU, without touching the main thread.
This is the key insight behind the "compositor-only properties" rule.
transform and opacity can be processed entirely by the compositor:
no JavaScript, no layout, no paint is needed. The compositor simply applies a matrix
transformation or alpha value to an already-rasterized layer tile and composites it.
At 60fps, this means 16ms per frame — and the compositor can hit that budget even while
the main thread is busy running JavaScript.
Elements promoted to their own compositor layer (via will-change: transform,
or naturally through 3D transforms, video, canvas, and some other triggers) get their own
GPU texture. The compositor can move, scale, or fade that texture without asking the main thread
for anything. This is why transform-based animations remain smooth even under main thread load.
/* ✅ These hit the compositor thread only */ .animate-cheap { transform: translateX(100px); /* compositor-only */ opacity: 0.8; /* compositor-only */ } /* will-change promotes element to its own compositor layer */ .will-animate { will-change: transform; /* pre-promote to GPU layer */ } /* ❌ These are NOT compositor-only — they trigger layout */ .animate-expensive { left: 100px; /* triggers reflow on positioned elements */ width: 200px; /* triggers layout recalculation */ margin: 20px; /* triggers full layout pass */ }
Chapter 03 — The Three Paths
Karma Yoga, Jnana Yoga, Bhakti Yoga
The Bhagavad Gita describes three paths to liberation: Karma Yoga (the path of action), Jnana Yoga (the path of knowledge), and Bhakti Yoga (the path of devotion). The rendering pipeline has its own three paths — and just like their Vedic counterparts, they have very different costs.
Triggers all 6 stages. Examples:
width, height,
padding, margin, font-size, top/left on positioned elements.
Triggers stages 5–6. Examples:
color, background-color,
box-shadow, border-color, outline.
Triggers stage 6 only. Examples:
transform, opacity.
Here's the reference table every frontend engineer should have memorised. When you're choosing how to animate a property, check which path it triggers:
| CSS Property | Path | Cost |
|---|---|---|
width, height, margin, padding | Reflow | ⚠️ High |
font-size, line-height, min-width | Reflow | ⚠️ High |
top, left, right, bottom (positioned) | Reflow | ⚠️ High |
color, background-color | Repaint | 🟡 Medium |
box-shadow, text-shadow | Repaint | 🟡 Medium |
border-color, outline | Repaint | 🟡 Medium |
transform | Composite | ✅ Low |
opacity | Composite | ✅ Low |
transform over top/left
for movement. Always prefer opacity over visibility for fade effects.
These two properties are the only ones that can animate at 60fps even when the main thread is under load.
Chapter 04 — Going Deeper
Blink & GPU Internals
The six stages are an abstraction. Under the hood, Chrome's Blink engine has a more nuanced architecture involving multiple threads, independent layer trees, and tile-based rasterization. If you want to understand why compositor-only animations work, this is the answer.
Chrome runs rendering across multiple threads. The main thread handles JavaScript, style calculation, layout, and paint record generation. A separate compositor thread handles scrolling, transforms, and the final assembly of GPU tiles into frames — completely independently of the main thread.
Impl-side painting means the compositor maintains its own copy of the
layer tree, separate from the main thread's layer tree. When you scroll or apply a
transform, the compositor can act immediately using its own tree — no
round-trip to the main thread needed. If the main thread is blocked on a long JavaScript
task, scrolling and transform animations still run at 60fps.
Tile rasterization is how layers are rasterized. Rather than rasterizing an entire layer at once (which could be huge for a long page), Blink divides each layer into tiles (typically 256×256 or 512×512 px). Tiles are rasterized independently, often on worker threads using the GPU. Only visible tiles are prioritized. This allows the browser to begin displaying content before the entire page has been rasterized.
will-change: transform tells the browser: "this element will
be animated with a compositor-only property." The browser responds by promoting the element
to its own compositor layer before the animation begins. This pre-promotion means
when the animation fires, no layer promotion cost is paid — the GPU texture is already there.
Use it on elements you know will animate, but don't use it everywhere — each
compositor layer consumes GPU memory.
The flow: Main thread produces paint commands → commands are transferred to compositor thread → compositor thread sends tile raster work to raster worker threads → rasterized tiles are uploaded to the GPU → compositor thread composites tiles → frame is displayed on screen. JavaScript only blocks the main thread. The compositor thread keeps the visual smooth.
Chapter 05 — See It Yourself
Inspecting the Pipeline in DevTools
The rendering pipeline isn't abstract — you can watch it execute in real time using Chrome DevTools. Here's exactly where to look.
Open DevTools (F12 or Cmd+Option+I) and navigate to the Performance tab.
- Click the record button, interact with your page, then stop recording.
- In the flame chart, look for the Main thread track — you'll see colour-coded tasks:
Parse HTML(blue),Recalculate Style(purple),Layout(yellow/orange),Update Layer Tree(teal). - The Frames track at the top shows each rendered frame — green frames are on-budget (≤16ms), red frames are dropped.
- Expand any task to see its call stack — you can trace exactly which JavaScript function triggered a reflow.
Paint Flashing: In DevTools, go to More tools → Rendering
(three-dot menu) and enable Paint Flashing. Regions that are being repainted
flash green. Scroll your page — if you see the entire viewport flashing, you have paint
performance issues. Elements that repaint on scroll should be on their own compositor layer.
Layer Borders: Also in the Rendering panel, enable Layer Borders to see orange borders around compositor layers and blue borders around tiles. This reveals exactly which elements have been promoted to GPU layers.
See it live
Step through each rendering stage interactively — parse HTML, build CSSOM, construct the render tree, compute layout, generate paint commands, and composite. Choose a scenario and watch the pipeline execute.
Open Visualizer →