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.

PatternCost
.nav-linkLow — single class lookup
nav ul li aHigher — 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:

  1. Backgrounds and borders
  2. Floats
  3. Foreground content (text, inline elements)
  4. 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 transform or opacity
  • Elements with will-change set 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 reflowTriggers repaint only
Changing width, height, margin, paddingChanging color, background-color, visibility
Adding or removing DOM nodesChanging 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