Last updated: Apr 29, 2026
Rendering Pipeline
To show a web page, the browser goes through a series of steps:
- Receive the HTML, CSS, and JavaScript from the server
- Parse the code into data structures (DOM and CSSOM)
- Calculate which styles apply to which elements
- Figure out the position and size of everything on the page
- Paint the visual content
- Composite the layers into the final image on screen
Different browsers implement these steps differently under the hood, but the general sequence is the same. A change early in the chain forces every later step to re-run.
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐
│ Parse │ → │ Style │ → │ Layout │ → │ Paint │ → │ Composite │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────────┘
DOM, CSSOM Render tree Position & Draw Layers →
+ computed size of each commands final frame
styles element (display list)
Parsing
The browser reads HTML top to bottom and builds the DOM (Document Object Model) — a tree where each HTML tag becomes a node.
CSS goes through a similar process, producing the CSSOM (CSS Object Model). The CSSOM is also a tree because styles cascade — child nodes inherit styles from their parents.
Blocking and non-blocking resources. CSS is render-blocking — the browser will not paint anything until the CSSOM is complete, because it needs to know what everything looks like first. Images and async scripts are non-blocking — the browser can keep parsing while they download in the background.
A plain <script> tag (without defer or async) pauses HTML parsing until the script downloads and runs. This is because the script might modify the DOM the browser is currently building. Script loading strategies are covered in Loading.
Both DOM and CSSOM must be ready before the next step.
Style Calculation
The browser combines the DOM and CSSOM into a render tree. It walks every DOM node, finds the matching styles from the CSSOM, and attaches the computed result. Nodes that produce no visual output — <script>, <meta>, and nodes with display: none — are never added to the render tree.
Relative values are resolved to absolute ones at this stage — font-size: 2em becomes font-size: 32px.
Selectors are matched right to left. For nav ul li a, the engine first finds every <a> on the page, then walks up each one’s ancestors to check if it sits inside li, then ul, then nav. Deep, generic selectors mean more ancestor checks. In practice, modern engines are fast enough that selector cost rarely matters outside very large DOMs with frequent style recalculations.
| Pattern | Cost |
|---|---|
.nav-link | Low — single class lookup |
nav ul li a | Higher — every <a> checked against three ancestor levels |
* (universal) | Highest — matches every element |
Layout
The browser walks the render tree and calculates the position and size of every element — width, height, margins, coordinates. The result is sometimes called a layout tree or box tree.
There are two kinds of layout:
- Global layout — the entire page is laid out from scratch. Happens on the first load or when the viewport is resized.
- Incremental layout — only the part of the tree that changed is recalculated. Happens after a small DOM or style change.
Layout is recursive. A parent’s size can depend on its children (content-based sizing), and children can depend on their parent (percentage widths). This makes layout one of the most expensive steps.
When a later change affects geometry — a DOM mutation, a style change to width or margin — the browser re-runs layout. This re-run is called a reflow.
Paint
The browser records draw commands for each element — “fill this rectangle”, “draw this text at (x, y)”. The ordered list of commands is called a display list. Nothing appears on screen yet at this point.
Elements are painted in stacking order:
- Backgrounds and borders
- Floats
- Foreground content (text, inline elements)
- Positioned elements, ordered by
z-index
A change that affects appearance but not geometry (color, background-color, box-shadow) triggers a repaint — paint re-runs but layout is skipped.
Compositing
The browser splits the page into layers. Most of the page lives on a single layer. Elements that get their own layer include:
- Elements with a 3D or perspective
transform <video>,<canvas>with WebGL or an accelerated 2D context- Elements with a CSS animation on
transformoropacity - Elements with
will-changeset to a compositable property - Elements that overlap a composited layer (implicit promotion)
Each layer is rasterized (turned into a bitmap) independently. The compositor, which runs on a separate thread from the main thread, takes these bitmaps and combines them into the final frame sent to the display.
CSS animations on transform or opacity are handled entirely by the compositor thread — the main thread is not involved frame-to-frame, so these animations stay smooth even when JavaScript is blocking. JS-driven changes (element.style.transform in requestAnimationFrame) still pass through the main thread for style recalculation, but skip layout and paint.
Each layer consumes GPU memory. Promoting too many elements wastes memory, especially on mobile. Modern browsers are good at deciding which elements need their own layer, so manual promotion with will-change or the old transform: translateZ(0) hack is rarely needed.
Reflow and Repaint
| Triggers reflow | Triggers repaint only |
|---|---|
Changing width, height, margin, padding | Changing color, background-color, visibility |
| Adding or removing DOM nodes | Changing box-shadow, outline |
Reading layout properties (offsetHeight, getBoundingClientRect) | Changing opacity (when on its own layer) |
Changing font-size, line-height | |
| Resizing the window |
Reading a layout property right after a DOM mutation forces a synchronous layout — the browser must flush pending style and layout changes immediately to return an accurate value. Batching reads and writes into separate passes avoids this.
Batching DOM Changes
Every DOM mutation that affects layout can trigger a reflow. One technique is to remove the element from the flow, apply all changes, then put it back:
const list = document.getElementById("list")!;
list.style.display = "none"; // 1 reflow — element leaves layout
// ... apply many mutations ...
list.style.display = ""; // 1 reflow — element re-enters layout
This results in 2 reflows instead of one per mutation. DocumentFragment does the same thing for new subtrees — nodes are built off-document and inserted in one operation:
const fragment = document.createDocumentFragment();
for (const text of items) {
const li = document.createElement("li");
li.textContent = text;
fragment.appendChild(li);
}
document.getElementById("list")!.appendChild(fragment); // 1 reflow