Experiments / 05

Critical Rendering Path

Why can’t the browser just show the page?

  • Advanced
  • 14 min
  • Impact ●●●

The problem

The HTML arrives, and the page doesn’t appear. It feels as though the browser should be able to show something the moment it has the text. Why can’t it?

Because showing a page isn’t one step. The browser has to build two separate models, combine them, work out where every box goes, then draw. Each stage needs the output of the one before it, and some of them need things from the network. If one input is late, everything behind it waits.

That sequence is the critical rendering path. The widget below runs it for a whole page load.

The rendering pipeline, live

Start with “A healthy page” and press Replay: watch which stages light up and in what order. Then try “Huge CSS file” and watch the Render tree card turn red while it waits.

Introduce a problem
40 KB
300 KB
Fix
Device CPU
10 Mbps
120 ms
First paint
946 ms
Text visible
946 ms
Largest paint
1.98 s
Layout shift
0.00
The pipeline2 critical resources · 70 KB
HTML
done
DOM×3
done
CSS
done
CSSOM
done
JavaScriptDeferred: runs after parsing
done
Render tree
done
Layout
done
Paint×2
done
Composite×2
done
The screen

example.com

Faster pages

Every stage of the pipeline has to finish before the next can show you anything.

complete

Network and main thread
  • HTML
  • CSS
  • JS
  • Image
  • Style
  • Layout
  • Paint
  • Composite
2.02 s

First paint waited for the CSS. The 40 KB stylesheet arrived at 737 ms and took 80 ms to turn into the CSSOM. The render tree needs it, because until the browser knows every rule it can’t know what anything looks like. The DOM, which was ready at 551 ms, simply waited.

The hero image arrived at 1.08 s but wasn’t decoded until 1.93 s: the deferred script held the main thread for 993 ms first. “Deferred” stops it blocking the parser, not blocking the page.

Illustrative rates. Hatched = waiting to start. Red top edge = main-thread task over 50 ms after first paint.

Things to try

  1. Watch the red card. Drag the CSS size up. The DOM finishes early, but the render tree sits blocked until the CSSOM exists.
  2. Stop the parser. Tick the synchronous script. The DOM card goes red, because the parser can’t continue, and the script itself waits for the CSS first.
  3. Make the hero huge. First paint doesn’t move, but the largest paint does. Different metrics measure different parts of the path.
  4. Hide the text. Tick the web-font option: the page paints at first paint, but there’s nothing readable until the font lands.
  5. Break the layout. Remove the image’s dimensions and watch Layout and Paint run a second time (the ×2), and the text jump.
  6. Fix it. Press “…then fix the CSS”, or tick “Inline critical CSS”, and compare first paint to the broken version.

The stages

HTML → DOM
The parser turns markup into a tree of nodes, and it does so incrementally as bytes arrive. It doesn’t wait for the whole document. While it parses, a lookahead “preload scanner” spots stylesheets, scripts and images early so their downloads can start.
CSS → CSSOM
Stylesheets are parsed into a model of every rule. The browser can’t safely use a partial CSSOM, because a later rule can override an earlier one, so it waits for all render-blocking CSS. That is why CSS is “render-blocking”.
Render tree
The DOM and the CSSOM are combined, keeping only what is visible and attaching its computed styles. display: none content isn’t in it.
Layout
Computes the size and position of every box. Sometimes called reflow. If a size changes later, layout has to run again.
Paint
Turns boxes into pixels: backgrounds, text, borders, images, in layers.
Composite
Layers are combined and sent to the screen, largely off the main thread. This is why animating only transform and opacity is cheap: those changes can skip layout and paint entirely.

Two kinds of blocking

Render-blocking resources (normally stylesheets in <head>) stop the browser from painting. Parser-blocking resources (a classic <script>) stop it from building the DOM, because the script might change the page as it runs.

They interact. A script has to wait for any stylesheet still loading, in case the script asks what colour something is. So a synchronous script after a big stylesheet makes both problems worse, as you saw in the widget.

Problems and what fixes them

Big render-blocking CSS
Ship less CSS. Inline the small amount needed for the first view and load the rest without blocking, or split styles by media or route. The widget’s “inline critical CSS” does this. The trade-off: the rest of the CSS arrives later and causes a restyle, and critical CSS must be kept in sync as the design changes.
Blocking script
defer runs the script after parsing, in order. async runs it as soon as it arrives, in any order. Module scripts are deferred by default. Neither makes the script itself cheap, as Experiment 03 showed.
Oversized image
It won’t delay first paint, but it sets your largest paint. Right-size and compress it, and don’t lazy-load it. See Experiment 02.
Web font
The font is requested only after the render tree exists. Choose a font-display behaviour, preload it, and keep it small. See Experiment 04.
Layout shift
Give images and embeds dimensions so layout runs once. Anything that changes a box’s size later forces layout, and often paint, again.

Common misconceptions

“The browser should show the page as soon as the HTML arrives.”

It can’t know what the page looks like. The HTML says what is on the page. The CSS says how it looks, and layout depends on that. Painting early with half the styles would mean painting the wrong thing and then repainting.

“A high Lighthouse score means my website is fast.”

Lighthouse measures one load, in a lab. It uses a fixed device and network profile. Your users have different phones, connections and cache states, and their experience is also shaped by what happens after load: whether taps respond, whether things jump. Lab scores (Lighthouse), field data (what real users measure) and perceived responsiveness can all disagree. A good score is a useful signal, and not proof.

“Make everything async and the render path is solved.”

Async and defer only change who blocks the parser. CSS still blocks rendering, scripts still occupy the main thread, and images and fonts still arrive late. Optimising one stage moves the bottleneck to the next.

See it on a real page

  1. Open DevTools, then Performance, and record a reload with CPU throttling on. In the Main track you’ll see Parse HTML, Recalculate Style, Layout, Paint and the same stalls as above. The Timings track marks FP, FCP and LCP.
  2. In Network, look at what is requested before first paint, and which requests are marked render-blocking.
  3. Ask the browser for the paint timings:
    performance.getEntriesByType('paint').forEach((e) => console.log(e.name, Math.round(e.startTime)));
  4. In the Rendering panel, turn on Layout shift regions and Paint flashing to see what re-runs and what jumps.
  5. Lighthouse’s “Eliminate render-blocking resources” and “Avoid large layout shifts” audits point at the same causes.

Model assumptions

What this simulation simplifies
  • Page: 30 KB of HTML, a stylesheet referenced early in the head, a 150 KB script, a hero image about a third of the way down, one connection (HTTP/2-like) sharing bandwidth.
  • Rates are illustrative: HTML 0.3 ms/KB, CSS 0.5 ms/KB, style, layout, paint and composite fixed costs scaled by the CPU. Real costs depend on the page’s complexity, not just its size.
  • First paint needs the CSSOM and the visible part of the DOM (the first 40% of the HTML). Real browsers can paint progressively, and painting is tied to the display’s refresh.
  • Main-thread work is one task at a time. When two tasks could start together, rendering goes first. Compositing runs on its own thread.
  • An image without dimensions causes one extra layout, paint and composite, and a layout shift of about 0.42 on an 800 px viewport.
  • Critical CSS is 14 KB inlined in the HTML. The rest loads at low priority and restyles the page afterward.