How Browsers Render a Page: The Full Pipeline


When a browser receives an HTML document, it doesn’t just display it. It parses it, builds several internal data structures, determines where everything goes, figures out how everything looks, and hands off the result to the GPU for painting. The whole process takes tens of milliseconds, and every stage you understand is a performance problem you can actually debug.

Parsing HTML → the DOM

The first thing the browser does is parse the HTML byte stream into a tree of nodes: the Document Object Model (DOM). Each HTML element becomes a node, nested according to the document structure.

HTML parsing is intentionally forgiving. Malformed HTML, missing closing tags, or unexpected nesting is handled according to the spec’s error recovery rules rather than rejecting the document. This is why browsers render broken HTML instead of showing an error.

Script tags pause this process. When the parser encounters a <script> without defer or async, it stops, downloads the script, executes it (because the script might call document.write, which modifies the HTML stream), and then continues parsing. This is why render-blocking scripts in <head> delay the page. Modern practice: put scripts at the end of <body>, or use defer to parse them after the HTML without blocking.

Parsing CSS → the CSSOM

In parallel with HTML parsing, the browser fetches and parses CSS into the CSS Object Model (CSSOM) - a tree mirroring the DOM, with style rules computed for each node.

CSS is render-blocking. The browser will not render anything until the CSSOM is complete. This is the opposite of HTML, which is streamed and partially rendered. The reason: a style rule later in a stylesheet can affect elements parsed earlier. The browser must have the full CSS before it knows how anything looks.

This is why large, unused CSS files hurt initial render - the browser is blocked waiting to parse styles it doesn’t need.

The render tree

The DOM and CSSOM are combined into the render tree - a tree of nodes that will actually be painted on screen. Nodes with display: none are excluded entirely. Pseudo-elements like ::before are included even though they’re not in the DOM.

Each node in the render tree is a render object that knows its style properties: color, font size, display type, position mode.

Layout (reflow)

With the render tree in hand, the browser computes the exact position and size of each element - the layout (also called reflow). This is where the box model plays out: margins, padding, borders, percentage widths relative to their parent, flexbox and grid calculations.

Layout is done in a top-down pass: each element’s dimensions depend on its parent’s, and a parent’s dimensions may depend on its children (for auto heights). The output is a set of rectangles with precise pixel coordinates.

Layout is expensive. It has to be re-run whenever anything changes that affects element geometry: adding or removing elements from the DOM, changing width or height, changing font size, toggling display between none and something else. A single style change can cascade into a full layout recalculation.

Paint

Paint converts the layout into drawing instructions - fill this rectangle red, draw this text in this font at these coordinates, draw this border. These instructions are recorded into paint records, organized into layers.

Paint is separate from compositing. The browser doesn’t immediately draw pixels here - it records what to draw.

Some properties trigger paint when changed: color, background-color, border, box-shadow. Others do not, because they’re handled at the compositing stage.

Compositing

The browser splits the page into layers, paints each layer independently, and then composites them together in the correct order - like stacking transparent sheets of acetate.

Layers are promoted for:

  • Elements with will-change: transform or will-change: opacity
  • Elements with CSS transforms or opacity animations
  • <video>, <canvas>, and <iframe> elements
  • Elements with position: fixed

Compositing happens on the GPU. Changing transform or opacity on a promoted layer skips layout and paint entirely - the GPU just applies the transform to the already-painted layer texture. This is why CSS animations on transform and opacity are smooth even when the CPU is busy: they’re off the main thread.

Changing left, top, width, or height instead of transform forces layout and paint on every frame. At 60fps, that’s one layout calculation every 16ms - a budget that’s easy to blow.

The critical rendering path

The sequence from bytes to first paint is the critical rendering path:

  1. HTML → DOM
  2. CSS → CSSOM (blocking)
  3. DOM + CSSOM → render tree
  4. Layout
  5. Paint
  6. Composite

Optimizing the critical rendering path means reducing what’s blocking step 2 and minimizing the work in steps 4–5. The practical levers:

Minimize render-blocking CSS. Inline critical styles for above-the-fold content, load the rest asynchronously with media attributes or JavaScript. Eliminate unused CSS.

Defer non-critical JavaScript. Use defer on scripts. Avoid synchronous scripts in <head>.

Avoid layout thrashing. Reading layout properties (offsetWidth, getBoundingClientRect) forces the browser to calculate layout immediately if anything has changed. Reading and then writing in a loop causes a layout calculation per iteration. Batch reads together before writes.

// Layout thrashing: read-write-read-write...
elements.forEach(el => {
  const height = el.offsetHeight; // forces layout
  el.style.height = (height * 2) + 'px'; // invalidates layout
});

// Batched: read all, then write all
const heights = elements.map(el => el.offsetHeight); // one layout
elements.forEach((el, i) => {
  el.style.height = (heights[i] * 2) + 'px';
});

Animate with transform and opacity. Keep animations off the layout and paint stages. If you’re animating position, use transform: translate(), not left/top.

Promote layers intentionally. will-change: transform hints to the browser to promote an element to its own layer before the animation starts, avoiding the cost of promotion at animation time. Don’t use it everywhere - each layer costs memory.

What this means in practice

The rendering pipeline is why:

  • A display: none toggle is cheaper than an opacity animation on an unpromoted element
  • Animations on transform feel smoother than animations on width
  • Moving DOM elements is expensive; moving their CSS properties is not
  • Fonts and large CSS files delay first paint even if the HTML is small
  • Chrome DevTools’ Performance tab shows “Layout”, “Paint”, and “Composite” as distinct colored bars - you can see exactly which stage is the bottleneck

When a page feels slow or janky, the rendering pipeline is the map. Know the stages and you know where to look.