Field Guide · Vol. 6
Bytes → Frames

The Critical Rendering Path.

Between an HTTP response and the first pixel on screen, the browser runs a precise sequence of steps. Knowing each one — and what blocks each one — is the difference between a page that feels instant and one that crawls.

01 · The Sequence

Six stages, in strict order

The path is fixed: parse HTML into a DOM, parse CSS into a CSSOM, marry them into a render tree, lay it out, paint it, composite the layers. Every visual change re-runs some suffix of this sequence — and the rules of which suffix decide your frame rate.

DOM
CSSOM
RENDER TREE
LAYOUT
PAINT
COMPOSITE
Expensive · main thread CPU Moderate · main thread CPU Cheap · GPU

Fig. 1 — The path runs left to right. Animations that only trigger the rightmost stage hit 60fps for free.

02 · Stage By Stage

What each step actually does

① DOM construction

The HTML parser tokenizes incoming bytes into start tags, end tags, and text, then assembles them into a tree of Node objects. Streamed — the parser doesn't wait for the whole document.

<html>
  <body>
    <h1>Hello</h1>
    <p>World</p>
  </body>
</html>

// becomes →  html → body → [h1 → "Hello", p → "World"]

What blocks it: a synchronous <script>. Parser stops, fetches and runs the script, then resumes. async and defer are the two escape hatches.

② CSSOM construction

Every stylesheet — external, inline, <style> — is parsed into a tree of style rules. The CSSOM is render-blocking by default: the browser refuses to paint until it's complete, because applying styles late would cause a flash of unstyled content.

⚠ Subtle: CSS doesn't block HTML parsing, but it DOES block script execution (because scripts might query computed styles via getComputedStyle). And blocked scripts block the parser. Transitive blocking is real.

③ Render tree

DOM + CSSOM are walked together to produce the render tree — only the nodes that will be displayed. Elements with display: none are omitted. visibility: hidden is included (it occupies space).

In the render tree

  • Every visible element
  • visibility: hidden elements (they take space)
  • Pseudo-elements like ::before, ::after
  • Text node fragments after style application

NOT in the render tree

  • display: none elements + descendants
  • <head>, <script>, <meta> tags
  • Elements with no rendering context

④ Layout (also called reflow)

The browser walks the render tree and computes geometry — the exact box (x, y, width, height) of every node. This is recursive: a parent's width affects children; a child's content can expand a parent. For complex pages, layout is the most expensive single stage.

Layout happens in the viewport's coordinate system. Anything that changes geometry — adding a node, changing font size, resizing the window — invalidates layout for some subtree and triggers a reflow.

⑤ Paint

Now that every box has a position, the browser fills in the pixels: colors, text glyphs, borders, shadows, gradients. Paint is typically split across multiple layers — separate bitmaps for elements that will be composited together (think Photoshop layers).

Anything that changes appearance without changing geometry — a color flip, a shadow update — re-paints only the affected layers, skipping layout.

⑥ Composite

Painted layers are handed to the compositor thread, which runs on the GPU. It stacks them, applies transforms and opacity, and produces the final frame. This stage is cheap — the GPU is built for this exact operation.

The whole reason transform: translate and opacity are fast: they're properties the compositor can apply at composite time without re-running paint.

03 · The Cost Table

Which CSS changes trigger what

This is the single most useful table in frontend performance. Memorize it.

Property changedTriggersCost
width, height, top, left, margin, padding, border, font-size, display Layout → Paint → Composite expensive
color, background, background-image, box-shadow, border-radius, visibility, outline Paint → Composite moderate
transform, opacity, filter, backdrop-filter (when on a composited layer) Composite only — GPU cheap

Fig. 2 — Animating left: 100px hits all three expensive stages every frame. transform: translateX(100px) hits only the cheap one.

✗ Re-layouts every frame

@keyframes slide {
  from { left: 0; }
  to   { left: 300px; }
}

✓ Composite-only animation

@keyframes slide {
  from { transform: translateX(0); }
  to   { transform: translateX(300px); }
}
04 · The Render-Blocking Trap

What stalls first paint

The browser cannot paint until both the DOM and the CSSOM are ready. Anything that delays either of those delays your user seeing anything.

HTML download
100ms
Blocking CSS
200ms
Blocking JS
300ms — parses + executes
First Paint
!
600ms

Fig. 3 — A render-blocking chain serializes. Eliminate one and the bar to its right starts earlier.

Script loading: three flavors

Default <script>

Fetched + executed synchronously. Parser STOPS while this happens.

Use only for tiny critical scripts inlined into <head>.

<script async>

Fetched in parallel, executed AS SOON AS it lands — parser pauses then. Order not guaranteed.

Use for independent scripts (analytics, error reporting).

<script defer>

Fetched in parallel, executed in order AFTER parse completes, before DOMContentLoaded.

Default choice for everything that depends on the DOM.

CSS optimizations

Use media attributes to descope blocking CSS

// Render-blocking on every device:
<link rel="stylesheet" href="print.css" />

// Only blocks during print preview:
<link rel="stylesheet" href="print.css" media="print" />

// Only blocks when viewport matches:
<link rel="stylesheet" href="wide.css" media="(min-width: 900px)" />

Non-matching media stylesheets are still downloaded — but at a lower priority and they don't block render.

05 · The Hidden Killer

Forced synchronous layout

The browser is smart: it batches DOM mutations and runs layout once per frame. You can ruin that by asking for layout information mid-batch, forcing the browser to flush early. Pros call this layout thrashing.

✗ Layout thrashing — N mutations cause N reflows
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
  const width = box.offsetWidth;   // READ — forces layout
  box.style.width = (width * 2) + 'px';  // WRITE — invalidates layout
});
// Next iteration: READ again → flushes pending writes → reflow. Repeat N times.
✓ Read all, then write all
const boxes = document.querySelectorAll('.box');
const widths = [...boxes].map(b => b.offsetWidth);  // all reads
boxes.forEach((box, i) => {
  box.style.width = (widths[i] * 2) + 'px';        // all writes
});
// One layout flush at the end of the frame. ~N times faster.

Properties that force layout (the danger list)

Reading any of these mid-mutation flushes pending writes:

// Geometry reads — always force layout if mutations are pending
offsetTop, offsetLeft, offsetWidth, offsetHeight
clientTop, clientLeft, clientWidth, clientHeight
scrollTop, scrollLeft, scrollWidth, scrollHeight
getBoundingClientRect()
getComputedStyle()
window.innerWidth, window.innerHeight

Rule of thumb: in a hot loop, never alternate reads and writes. Read all, write all. If you must mix, use requestAnimationFrame to batch writes to the next frame.

06 · Toolkit

How to measure all of this

You cannot optimize what you cannot see. Every modern browser ships the tools.

DT
DevTools Performance tab
Record a page load or interaction. The timeline shows layout (purple), paint (green), composite (yellow-green), and JS (yellow). Long red bars = long tasks.
RP
Rendering panel
Enable "Paint flashing" to see green flashes wherever the browser repaints. "Layer borders" shows composited layers in orange.
CWV
Core Web Vitals
LCP — Largest Contentful Paint (≤2.5s). CLS — layout shift (≤0.1). INP — interaction → next paint (≤200ms).

Promoting an element to its own composited layer

When you know an element will animate or change frequently, hint to the browser to put it on its own GPU layer. Then composite-only changes stay composite-only.

// Modern way — declarative, browser decides when to promote
.fab {
  will-change: transform;
}

// Old hack — forces compositing via 3D transform
.fab {
  transform: translateZ(0);
}
⚠ Don't over-promote: every layer costs GPU memory. Promote elements that need it (animated, fixed, frequently changing) — not every button on the page. will-change on hundreds of elements will tank perf, not save it.

The interview answer

"The critical rendering path is the six-step pipeline from HTML/CSS bytes to pixels: build the DOM, build the CSSOM, combine them into a render tree, compute layout, paint each layer, and composite layers on the GPU. Every visual change re-runs some suffix of this path. The optimization game has two halves — minimize what blocks first paint (defer non-critical JS, scope CSS with media, inline critical styles), and minimize the cost of subsequent updates (animate transform/opacity to stay on the compositor, batch DOM reads and writes to avoid layout thrashing)."

Five rules that fall out of this

  1. Animate transform and opacity only. They skip layout and paint.
  2. Defer JS that doesn't need to run before paint. defer is the safe default.
  3. Read DOM geometry once per frame, then write. Mixing is a perf bomb.
  4. Use media queries on stylesheet links to descope blocking CSS by device or viewport.
  5. Promote with will-change sparingly. Only on elements you actually animate.
Critical rendering path field guide · Frontend Field Guides

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

Keep reading