CSSHow Browsers Render CSS

How Browsers Render CSS

When you open a web page, the browser does a remarkable amount of work in milliseconds to turn your HTML and CSS into the visual display you see. Understanding this pipeline — even at a high level — makes you a better CSS developer. It explains why certain animations are smooth while others are janky, why some CSS changes feel instant while others cause visible flicker, and what "render-blocking" actually means.

The critical rendering path

Text
Browser receives HTML bytes from the server
          │
          ▼
    Parse HTML → DOM Tree
    (Document Object Model — a tree of every element)
          │
          ├──────────────────────────────────────┐
          │                                      │
    Encounter <link> for CSS            Encounter <img> or <script>
          │                                      │
          ▼                                      ▼
    Fetch & parse CSS → CSSOM Tree      Load in parallel
    (CSS Object Model — all style rules)
          │
          ▼
    DOM + CSSOM → Render Tree
    (only visible elements; display:none excluded)
          │
          ▼
    Layout (Reflow)
    Calculate exact size and position of every element
          │
          ▼
    Paint
    Fill pixels — colours, text, borders, shadows
          │
          ▼
    Composite
    Combine painted layers (GPU-accelerated)
          │
          ▼
    Display on screen
Why CSS is render-blocking

CSS is described as render-blocking because the browser will not render anything until it has both the DOM and the CSSOM. If you have a large stylesheet that takes 500ms to download, the user sees a blank screen for 500ms. This is why:

  • <link rel="stylesheet"> belongs in the <head> — so it starts downloading as early as possible.

  • Keeping stylesheets small and well-split by priority matters for perceived load speed.

  • Critical CSS — inlining the styles needed for the visible "above the fold" content — is a real performance technique that eliminates this blank-screen delay for the first paint.

JavaScript is also render-blocking by default — a <script> tag without `async` or `defer` stops HTML parsing entirely while the script downloads and executes
CSS blocks rendering; JavaScript blocks both rendering AND HTML parsing. A `<script src="app.js">` in the `<head>` without `defer` forces the browser to stop building the DOM entirely. This is why classic advice says "put scripts at the bottom of `<body>`" or "always use `defer`". CSS doesn't have this two-step problem — fetching it in the head is fine as long as the file isn't enormous.
Layout, Paint, and Composite

After the render tree is built, the browser goes through three phases that are important to understand for performance:

Phase

What happens

Triggered by changing...

Layout (Reflow)

Calculate every element's size and position

width, height, margin, padding, font-size, display

Paint

Fill pixels with colour, text, borders, shadows

color, background, box-shadow, border-color

Composite

Combine GPU layers into the final frame

transform, opacity, filter, will-change

Layout is the most expensive phase because changing one element's size can affect the positions of dozens or hundreds of others — the browser has to recalculate the whole tree. Paint is cheaper but still significant. Composite operations happen on the GPU and are extremely fast.

This is why CSS animations that only change transform and opacity are smooth at 60fps while animations that change width or top often stutter — the former only triggers Composite, the latter triggers full Layout → Paint → Composite on every frame.

The CSSOM in practice

You rarely interact with the CSSOM directly, but it's useful to know it exists. JavaScript can read and modify it through document.styleSheets, element.style, and getComputedStyle(). When you set element.style.color = 'red' in JavaScript, you're modifying the CSSOM, which triggers a repaint of that element.

JS
// Reading a computed style — what the browser actually applied:
const element = document.querySelector('h1')
const styles = getComputedStyle(element)
console.log(styles.fontSize)        // e.g. "32px" — the resolved value
console.log(styles.fontFamily)      // e.g. "system-ui, sans-serif"

// Writing a style — modifies the element's inline style:
element.style.color = 'navy'        // triggers a repaint

// Reading the color you just set:
console.log(element.style.color)    // "navy"
// But computed style gives you the resolved value (same here since you set it explicitly)
console.log(getComputedStyle(element).color)  // "rgb(0, 0, 128)"
getComputedStyle() always returns values in a canonical form — colours become rgb() regardless of how you wrote them, lengths become px values, etc.
When you set `background: #fff` and then read it back with `getComputedStyle`, you'll get `rgb(255, 255, 255)`, not `#fff`. The browser converts all values to their resolved, canonical form in the CSSOM. This is important when writing JavaScript that reads styles — don't assume the format will match what you wrote.
Layer promotion and the GPU

When the browser identifies elements that change frequently (animated elements, fixed headers, sticky sidebars), it can promote them to their own compositor layer, painted separately on the GPU. Changes to that layer — like moving it with transform: translateX() — don't require Layout or Paint at all; the GPU just repositions the existing painted layer.

You can hint to the browser that an element will change with will-change:

CSS
/* Tells the browser "this element will be animated" — it may promote it
   to its own layer ahead of time, reducing animation startup cost */
.animated-card {
  will-change: transform;
}

/* Only use will-change on elements that genuinely will animate.
   Overusing it creates many GPU layers and actually hurts performance. */
Don't apply will-change to everything — each promoted layer consumes GPU memory; using it on the whole page can make performance worse, not better
`will-change` is a performance hint, not a magic speed button. Adding `will-change: transform` to your `body` or every element promotes the entire page to GPU layers, consuming significant memory and potentially slowing down the compositor. Apply it surgically to elements that are actively animating, and remove it after the animation completes if possible.
What this means for your CSS
  • Animate transform and opacity — they only trigger Composite, making them 60fps candidates.

  • Avoid animating width, height, top, left — they trigger Layout every frame.

  • Minimise render-blocking CSS — serve critical styles inline, defer non-critical stylesheets.

  • Avoid reading layout properties immediately after writing them — it forces the browser to recalculate layout synchronously (a "layout thrash").

  • Use will-change sparingly and intentionally — only on elements you know will animate.

Next
The DevTools panel where you can see and interact with everything covered on this page: [DevTools for CSS](/css/devtools-intro).