# How the Browser Renders a Page

> What happens between the browser receiving HTML/CSS bytes and pixels showing on screen - parsing, the render tree, layout, paint, and why some style changes are far more expensive than others.


---

# How the Browser Renders a Page

You know HTML, CSS, and the DOM. You've built pages. But when the browser turns your markup into pixels,
something happens in between that most tutorials skip - and it explains two things that otherwise feel
like magic: why a stray `<script>` tag can freeze your page mid-load, and why animating `left` feels
janky while animating `transform` stays buttery smooth.

This guide opens that gap. Three phases, each building on the last: how bytes become trees, how trees
become boxes on screen, and why some changes to those boxes cost far more than others.

## How to read this
- **Want the performance payoff fast?** Jump to [Phase 3: Why Some Changes Are Expensive](03-why-some-changes-are-expensive.md).
- **Want it to actually click?** Read in order - parsing sets up the render tree, the render tree sets up why costs differ.

## The phases
1. **[Parsing: From Bytes to DOM and CSSOM](01-parsing-from-bytes-to-dom-and-cssom.md)** - how the browser streams HTML into a tree while it's still downloading, why an unmarked `<script>` tag blocks that process, and how CSS becomes its own tree in parallel.
2. **[The Render Tree, Layout, and Paint](02-the-render-tree-layout-and-paint.md)** - how DOM and CSSOM combine into what actually gets drawn, why `display: none` and `visibility: hidden` behave completely differently, and how the browser computes geometry and fills in pixels.
3. **[Why Some Changes Are Expensive](03-why-some-changes-are-expensive.md)** - the real cost of changing geometry versus color versus `transform`/`opacity`, and how to avoid layout thrashing in your own code.

> Browser internals like the compositor's tiling strategy or GPU layer promotion rules are deep enough for their own guide - this one gives you the mental model that makes the DevTools Performance tab make sense.


---

# Parsing: From Bytes to DOM and CSSOM

The browser doesn't wait for the whole HTML file to arrive before it starts working. It reads bytes as
they stream in over the network and builds the page incrementally - which is why a slow server can still
show you a half-rendered page instead of a blank one. Here's what's actually happening in that stream.

## HTML bytes become the DOM

**What it actually is.** The HTML parser reads raw bytes, decodes them into characters, turns those into
tokens (`<div>`, `class="card"`, text), and builds tokens into DOM nodes as it goes. Each node gets
attached to its parent immediately - the tree grows live, not all at once at the end.

**What it does in real life.** This is why `view-source` on a slow-loading page can show content
appearing top to bottom: the browser is parsing and attaching nodes the moment enough bytes have
arrived to form them.

**A real example.**

```html
<!DOCTYPE html>
<html>
  <head><title>Demo</title></head>
  <body>
    <h1>Hello</h1>
    <p>World</p>
  </body>
</html>
```

The parser sees `<html>`, opens the tree. Sees `<head>`, attaches it as a child. Sees `<title>Demo</title>`,
attaches that text node. By the time it reaches `<p>World</p>`, `<h1>Hello</h1>` is already a live node in
the tree - even though the rest of the file hasn't arrived yet.

**The gotcha: `<script>` blocks parsing.** When the parser hits a plain `<script src="...">` (no `defer`,
no `async`), it stops building the DOM entirely. It has to: the script might call
`document.write()` or read/modify the DOM built so far, so the browser can't safely keep going until
that script downloads and runs.

```html
<p>This renders instantly.</p>
<script src="analytics.js"></script>
<p>This waits for analytics.js to download AND execute.</p>
```

If `analytics.js` is slow, that second `<p>` doesn't exist yet - not only visually, but in the DOM itself -
until the script finishes. This is the single most common accidental performance bug in real sites: one
`<script>` tag dropped in the middle of a page, quietly blocking everything after it.

**Why this saves you later.** `defer` tells the browser to keep parsing and run the script after the DOM
is complete, in order. `async` also keeps parsing going but runs the script the moment it's downloaded,
in whatever order downloads finish. For anything that isn't modifying the page during load, reach for
one of the two:

```html
<script src="analytics.js" defer></script>
```

Now parsing continues uninterrupted, and `analytics.js` runs once the DOM is ready.

## CSS bytes become the CSSOM

**What it actually is.** CSS goes through the same journey - bytes, tokens, tree - but the result is
called the CSSOM (CSS Object Model), not the DOM. It happens in parallel with HTML parsing, on its own
timeline.

**Why people get this wrong.** It's tempting to assume CSS "applies" to the DOM directly once parsed. It
doesn't merge with the DOM at parse time - it stays a separate tree of style rules until a later step
(covered in Phase 2) combines the two.

**The gotcha: CSS blocks rendering, not parsing.** The browser keeps parsing HTML into the DOM even while
a stylesheet is loading. But it won't paint anything to the screen until the CSSOM is complete - because
showing unstyled content and then snapping in styles a moment later (a flash of unstyled content) is worse
than a short blank pause. This is why `<link rel="stylesheet">` in `<head>` is standard: get CSS
downloading immediately, before the browser has anything worth painting anyway.

**Why this saves you later.** A giant CSS file, or a slow CSS request, delays first paint even if your
HTML and images are ready. That's the real reason "keep your critical CSS small" is common performance
advice - it's not about parsing speed, it's about how long the browser waits before it's allowed to draw.

## Parsing and CSSOM, side by side

```mermaid
flowchart LR
  HTML[HTML bytes] --> Tokens1[HTML tokens]
  Tokens1 --> DOM[DOM tree]
  CSS[CSS bytes] --> Tokens2[CSS tokens]
  Tokens2 --> CSSOM[CSSOM tree]
  DOM --> Next[Phase 2: Render tree]
  CSSOM --> Next
```

Both trees build independently and both are needed before the browser can move to the next stage - it
can't build a render tree from an incomplete DOM or a half-parsed CSSOM.

## Recap

1. The DOM builds incrementally as HTML bytes stream in - the tree grows live, not all at once.
2. A `<script>` tag without `defer`/`async` halts DOM construction until it downloads and runs.
3. CSS parses into the CSSOM in parallel with HTML parsing, but the browser won't paint until CSSOM is complete.
4. `defer`/`async` exist to let scripts load without stalling the DOM.

Check your understanding of the parsing stage before moving to what the browser builds next.

```quiz
[
  {
    "q": "Why does a plain <script src=\"...\"> tag (no defer/async) stop HTML parsing?",
    "choices": ["The browser can't download two things at once", "The script might modify the DOM, so parsing must pause until it finishes", "Scripts are always larger than HTML files"],
    "answer": 1,
    "explain": "The script could call document.write() or otherwise change the DOM, so the browser halts parsing until the script downloads and runs."
  },
  {
    "q": "What does the CSSOM come from?",
    "choices": ["The DOM tree, after it's built", "CSS bytes, parsed independently of HTML", "JavaScript computing styles at runtime"],
    "answer": 1,
    "explain": "CSS parses into its own tree, the CSSOM, in parallel with HTML parsing into the DOM."
  },
  {
    "q": "What does defer do differently from a plain script tag?",
    "choices": ["Downloads the script faster", "Lets HTML parsing continue, then runs the script after the DOM is complete", "Skips running the script entirely"],
    "answer": 1,
    "explain": "defer keeps parsing going and runs the script in order once the DOM is ready, instead of blocking parsing to run it immediately."
  }
]
```


---

# The Render Tree, Layout, and Paint

You've got a DOM tree and a CSSOM tree from Phase 1. Neither one, alone, is enough to draw anything - the
DOM doesn't know what's visible or where, and the CSSOM doesn't know what elements exist. The browser
combines them into something new, then does two more passes before a single pixel changes color.

## Building the render tree

**What it actually is.** The render tree is DOM nodes merged with their computed CSS, but with one
important filter: only nodes that will actually be visible on the page make it in.

**What it does in real life.** `<head>`, `<script>`, and `<meta>` tags never appear in the render tree -
they produce no visual box, so there's nothing to render. The same is true for any element styled with
`display: none`.

**The gotcha: `display: none` vs `visibility: hidden`.** These sound similar and behave nothing alike.

```css
.a { display: none; }
.b { visibility: hidden; }
```

`display: none` removes the element from the render tree entirely - it takes up no space, as if it
weren't in the document. `visibility: hidden` keeps the element in the render tree, gives it a box, sets
aside its layout space - it's invisible, nothing more. Toggle `display: none` off and the whole page around it
reflows. Toggle `visibility: hidden` off and nothing else moves, because the space was reserved the whole
time.

```console
$ # Both hide the element visually, but:
$ # display: none    → element has no box, siblings shift to fill the gap
$ # visibility: hidden → element keeps its box, siblings don't move
```

*What just happened:* there's no command to run here, but open DevTools on any page, toggle each property
on an element, and watch the layout. `display: none` collapses the space; `visibility: hidden` leaves a
hole exactly the element's size.

**Why this saves you later.** Reach for `visibility: hidden` (or `opacity: 0`, covered in Phase 3) when
you want to hide something without the rest of the page jumping around - a common need for tooltips,
tabs, and toggles where a layout shift would be jarring.

## Layout: computing the geometry

**What it actually is.** Layout (also called reflow) is the pass where the browser walks the render tree
and calculates the exact pixel position and size of every box - starting from the viewport width and
working down through every nested element's margins, padding, and content.

**What it does in real life.** A `<div>` with `width: 50%` has no actual size until layout runs the math:
50% of what? The browser has to know the parent's width first, which depends on its parent, all the way
up to the viewport. This is why layout is inherently a tree-wide calculation, not a per-element lookup.

**A real example.** Say you have:

```html
<div style="width: 400px;">
  <p style="width: 50%; padding: 10px;">Text</p>
</div>
```

Layout resolves the outer `div` to 400px wide (fixed), then resolves the `p` to 200px content width
(50% of 400px) plus 20px of padding, for a final box of 220px. Every box's final geometry depends on
boxes above it.

**The gotcha: layout is expensive precisely because it's not isolated.** Changing one box's width can
ripple through every box after it and every box nested inside it. The browser is efficient about
recalculating only what changed where it can, but a change near the root of a deep tree can force a
recalculation of most of the page.

**Why this saves you later.** Layout cost is why "changing an element's size or position" and "changing
its color" are not the same kind of operation to the browser - Phase 3 makes that difference concrete.

## Paint: filling in the pixels

**What it actually is.** Once every box has a final size and position, paint fills in the actual visual
detail inside each box - text, colors, borders, shadows, images - onto layers the browser will later
combine into the final image.

**What it does in real life.** Paint runs after layout, using the geometry layout already computed. It
doesn't decide where things go; it decides what they look like once they're already placed.

**Why this saves you later.** Not every visual change needs layout redone first. If a box's size and
position haven't changed - only its background color, say - the browser can skip straight to paint. That
distinction is the whole story of Phase 3.

## Recap

1. The render tree is the DOM merged with computed CSS, minus anything with no visual box (`<head>`, `display: none`).
2. `display: none` removes the box and its space; `visibility: hidden` keeps the space, hides the content.
3. Layout computes the exact size and position of every box, and one box's change can ripple through the tree.
4. Paint runs after layout, filling in colors, text, and borders using the geometry layout produced.

Check that the render tree and layout stuck before we get to which changes cost what.

```quiz
[
  {
    "q": "Which elements are excluded from the render tree?",
    "choices": ["Only elements with no text content", "Elements with no visual box, like <head> or display: none elements", "Elements styled with visibility: hidden"],
    "answer": 1,
    "explain": "The render tree only includes nodes that produce a visual box. visibility: hidden still gets a box; display: none does not."
  },
  {
    "q": "If you toggle an element from visibility: hidden to visible, what happens to the surrounding layout?",
    "choices": ["Everything around it shifts to make room", "Nothing shifts - the space was already reserved", "The whole page reloads"],
    "answer": 1,
    "explain": "visibility: hidden keeps the element's box in the render tree and its space reserved in layout, so making it visible again doesn't move anything else."
  },
  {
    "q": "What does the layout (reflow) pass calculate?",
    "choices": ["The colors and text rendering of each box", "The exact size and position of every box in the tree", "Which JavaScript event handlers to attach"],
    "answer": 1,
    "explain": "Layout walks the render tree and computes each box's final geometry - width, height, and position - which paint then uses to draw."
  }
]
```


---

# Why Some Changes Are Expensive

You've seen layout and paint in Phase 2. Here's the payoff: not every style change triggers both. Which
pipeline stages re-run depends entirely on which CSS property you touch - and that difference is why one
animation stutters while another feels free.

## The three tiers of cost

**What it actually is.** After the initial render, any DOM or style change re-triggers some subset of
layout, paint, and composite (the step that assembles painted layers into the final image on screen,
often on the GPU). Which subset depends on the property changed.

**Tier 1 - geometry changes trigger everything.** `width`, `height`, `top`, `left`, `margin`, `padding` -
anything that can change a box's size or position - forces layout to re-run, which invalidates the
affected paint, which has to be re-composited.

```css
.box { left: 0; }
.box.moved { left: 200px; }
```

Animating `left` means every frame re-runs layout for that box (and potentially its neighbors), repaints
it, and recomposites. On a complex page, that's real work happening 60 times a second.

**Tier 2 - paint-only changes skip layout.** `background-color`, `color`, `box-shadow`, `border-color` -
anything that changes appearance without changing size or position - skips layout entirely and goes
straight to paint, then composite.

```css
.box { background-color: blue; }
.box:hover { background-color: red; }
```

No geometry changed, so layout has nothing to recalculate. Cheaper than Tier 1, but still repaints pixels
every frame if animated.

**Tier 3 - `transform` and `opacity` can skip both.** These two properties are the browser's fast path.
Modern browsers can promote an element to its own compositor layer and handle `transform` and `opacity`
changes entirely on the compositor thread - no layout, no paint, only the GPU repositioning or fading an
already-painted layer.

```css
.box { transform: translateX(0); }
.box.moved { transform: translateX(200px); }
```

Visually, this achieves the same slide as animating `left` - but the browser never touches layout or
paint to do it. That's why `transform`/`opacity` are the standard advice for smooth CSS animations:
they're the only properties with a realistic path to running entirely off the main thread.

**Why this saves you later.** When an animation feels janky in DevTools' Performance tab and you see
purple (layout) and green (paint) bars on every frame, the fix is usually swapping a geometry property
for its `transform` equivalent - `left`/`top` becomes `translate()`, `width`/`height` scaling becomes
`scale()`.

## Layout thrashing: the trap you can cause in JavaScript

**What it actually is.** Reading a layout-dependent property (like `offsetHeight` or `getBoundingClientRect()`)
forces the browser to run any pending layout immediately, instead of waiting for its normal schedule.
Interleave reads and writes in a loop, and you force layout to run over and over in the same frame.

**A real example.**

```js
// Bad: forces layout on every iteration
boxes.forEach(box => {
  box.style.width = box.offsetWidth + 10 + 'px'; // read, then write, then read again next loop
});
```

Each `.offsetWidth` read after a `.style.width` write forces the browser to recalculate layout right then,
because it can't answer "what's the width now?" without running the math first. With a hundred boxes,
that's a hundred forced layouts in one loop - "layout thrashing."

```js
// Good: batch all reads, then all writes
const widths = boxes.map(box => box.offsetWidth); // all reads first
boxes.forEach((box, i) => {
  box.style.width = widths[i] + 10 + 'px'; // then all writes
});
```

*What just happened:* separating reads from writes lets the browser do one layout pass for all the reads,
then one for all the writes, instead of alternating and recalculating every iteration.

**Why this saves you later.** This pattern - read everything, then write everything - is the single
biggest lever you control in your own code for keeping layout cost predictable. Libraries like FastDOM
automate the batching, but the underlying rule is one you can apply by hand.

## Recap

1. Geometry properties (`width`, `left`, `margin`) trigger layout, paint, and composite - the full pipeline.
2. Paint-only properties (`background-color`, `box-shadow`) skip layout but still repaint.
3. `transform` and `opacity` can skip both layout and paint, running on the compositor thread alone.
4. Reading layout properties (`offsetWidth`) after writing styles forces synchronous layout - batch reads before writes to avoid layout thrashing.

One more check before you go - which properties actually determine an animation's cost.

```quiz
[
  {
    "q": "Why are transform and opacity the recommended properties for smooth animation?",
    "choices": ["They're newer CSS properties", "They can run entirely on the compositor thread, skipping layout and paint", "They use less CSS syntax"],
    "answer": 1,
    "explain": "Browsers can promote an element to its own layer and animate transform/opacity purely on the compositor, without re-running layout or paint."
  },
  {
    "q": "What causes 'layout thrashing' in JavaScript?",
    "choices": ["Using too many CSS classes", "Interleaving layout reads (like offsetWidth) with style writes in a loop", "Animating opacity instead of transform"],
    "answer": 1,
    "explain": "Each read of a layout-dependent property after a write forces the browser to run layout immediately, and alternating reads/writes in a loop forces it repeatedly."
  },
  {
    "q": "Animating background-color instead of left is cheaper because it:",
    "choices": ["Skips layout but still repaints", "Skips both layout and paint", "Skips composite only"],
    "answer": 0,
    "explain": "background-color doesn't change any box's size or position, so layout is skipped - but the pixels still need repainting."
  }
]
```

Where to go next: this guide covered the rendering pipeline itself. For turning that knowledge into
measurable page-speed improvements - Core Web Vitals, loading strategy, and what to prioritize -
see [Web Performance & Core Web Vitals](/guides/web-performance-core-web-vitals). To make sure your
layouts hold up across screen sizes without triggering unnecessary reflows, see
[Responsive Design](/guides/responsive-design).
