Topics
Web Performance

The Critical Rendering Path

How a browser turns HTML, CSS and JS into pixels, which resources block which step, and why some CSS changes cost a layout while others cost nothing.

Intermediate·14 min read·Updated Sep 30, 2026

The critical rendering path is the sequence of steps a browser runs to turn bytes into pixels: parse HTML into the DOM, parse CSS into the CSSOM, combine them into a render tree, compute layout, paint, then composite layers onto the screen. A resource that blocks a step (a stylesheet, a synchronous script, a web font) delays the first pixel; work that repeats a step on every frame (a layout per scroll event, an animated width) makes the page stutter. Knowing which step each thing touches is what turns "make it faster" into a specific change.

Context

The rules of thumb came first. Steve Souders' 2007 book High Performance Web Sites told everyone to put stylesheets at the top and scripts at the bottom without quite explaining the machinery. Around 2013-2014 Ilya Grigorik's Google guides named the machinery "the critical rendering path" and described the DOM → CSSOM → render tree → layout → paint model that every engine shares: Blink in Chrome and Edge, WebKit in Safari, Gecko in Firefox. The tools to shape it arrived in steps: speculative parsing (the preload scanner, around 2008) so a blocking script does not stall downloads, async and defer in HTML5, <link rel="preload"> (2016), content-visibility (2020) and fetchpriority (2022).

You have met the path as Lighthouse's "Eliminate render-blocking resources", as the purple Layout and green Paint bars in the DevTools Performance panel, as a flash of unstyled text while a font loads, and as the advice to animate transform instead of left. The simplest example is a page with one stylesheet and one script:

index.html
<head>
  <link rel="stylesheet" href="app.css">   <!-- nothing paints until this parses -->
  <script src="app.js"></script>           <!-- HTML parsing stops here until it runs,
                                                and it cannot run until app.css is ready -->
</head>
<body><h1>Hello</h1></body>
<!-- first pixel = HTML + app.css + app.js, in that order, not in parallel -->
DOM
Document Object Model: the tree of nodes built from HTML, including elements CSS will hide.
CSSOM
CSS Object Model: every rule from every stylesheet, resolved into the computed style each node will get.
Render tree
DOM plus CSSOM, minus what is not rendered (head, display: none), plus what CSS generates (::before).
Layout (reflow)
Computing the geometry: where every box goes and how big it is. Changing one size can move everything after it.
Paint
Turning boxes into pixels in layers: text, colours, borders, shadows, images.
Composite
Sending the painted layers to the GPU and combining them, applying transforms and opacity, into the final frame.

Why it matters

This is the mechanism under the Core Web Vitals: a slow LCP is usually a render-blocking step, a bad INP is usually a layout or paint that ran inside an interaction, and CLS is layout happening after the first paint. It is also the difference between a "fast" page that feels heavy and a page that feels instant, because users perceive the first paint and the smoothness of scrolling, not the total transfer size.

The pipeline

HTML bytesDOMCSS bytesCSSOMJavaScriptreads / writes bothrender treelayoutpaintcompositepixels on screen
From bytes to pixels. HTML and CSS are parsed into two trees that meet in the render tree; JavaScript can modify either. The first frame runs every box once; later frames only re-run the boxes a change invalidates.
  1. 1
    Parse HTML → DOM. Bytes are decoded, tokenised and built into a tree incrementally, so the parser can start before the document has finished downloading. A <script> without async/defer stops the parser until it has run, because the script may call document.write or query the DOM built so far.
  2. 2
    Parse CSS → CSSOM. Every stylesheet is fetched and parsed into rules; the cascade cannot be resolved until all of it is known, because a later rule can override an earlier one. That is why CSS blocks rendering: painting before the CSSOM is complete would show wrong styles, then reflow.
  3. 3
    Render tree. Walk the DOM, skip nodes that do not render (head, display: none), attach each visible node's computed style. visibility: hidden stays in the tree because it still takes up space.
  4. 4
    Layout. Starting from the viewport, compute the box geometry of every node in the tree: widths depend on parents, heights on children, so one change can cascade. On the first frame this is the whole page; later it is a "dirty" subtree, unless something forces a full one.
  5. 5
    Paint. Record drawing commands for each node into layers: fill this rect, draw this text run, this shadow. Expensive for large areas, blur filters and big images.
  6. 6
    Composite. Upload layers as textures and combine them, applying transform and opacity, on the compositor thread and GPU. A change that only needs this step never touches the main thread's layout or paint, which is why it is the cheapest kind.

What blocks what

Three rules explain nearly every waterfall. CSS is render-blocking: parsing continues, but nothing paints until the CSSOM is built. A synchronous script is parser-blocking: the HTML parser waits for it to download and run. And because a script might read computed styles, the browser will not run a script until the stylesheets above it have loaded, so CSS blocks JavaScript, and JavaScript blocks HTML. The preload scanner softens this by reading ahead and starting downloads, but it cannot start execution or painting.

Script formDownloadRunsBlocks parserOrder kept
<script src>Immediately (parser pauses)As soon as downloaded, after preceding CSSYes, for download and executionYes
asyncIn parallel with parsingAs soon as downloaded, whenever that isOnly during executionNo: whichever arrives first runs first
deferIn parallel with parsingAfter parsing completes, before DOMContentLoadedNoYes, in document order
type="module"In parallel, plus its importsDeferred by default (async if marked)NoYes, in document order
<link rel="stylesheet">Immediately, high priorityn/aNo, but blocks rendering and scripts below itCascade order
media="print" stylesheetLow priorityn/aNo, and does not block renderingn/a
blocking.html
<head>
  <link rel="stylesheet" href="/vendor.css">   <!-- 90 KB, mostly unused -->
  <link rel="stylesheet" href="/app.css">
  <script src="/analytics.js"></script>        <!-- third party, parser stops -->
  <script src="/app.js"></script>              <!-- waits for both CSS files -->
  <link href="https://fonts.example/inter.css" rel="stylesheet">  <!-- +1 origin -->
</head>
<!-- first paint waits on: 3 stylesheets + 2 scripts + a font CSS from a new origin -->
unblocked.html
<head>
  <style>/* critical CSS for above-the-fold, ~10 KB */</style>
  <link rel="preload" href="/app.css" as="style"
        onload="this.rel='stylesheet'">        <!-- rest of CSS, non-blocking -->
  <link rel="preload" href="/inter.woff2" as="font"
        type="font/woff2" crossorigin>         <!-- font found before CSS parses -->
  <script src="/app.js" defer></script>        <!-- parses in parallel, runs after -->
  <script src="/analytics.js" async></script>  <!-- never blocks anything -->
</head>
<!-- first paint waits on: HTML + inline CSS. Everything else streams in behind it -->

After the first paint: what each change costs

Once the page is up, every change re-runs part of the pipeline, and the browser has about 16 ms per frame at 60 Hz to do it. Which stages a change triggers depends on the property. Geometry triggers all of layout, paint and composite; appearance skips layout; transform and opacity on their own layer skip paint too.

JS / CSS changeclass toggle, style write, animation tick
stylerecalc which rules match
layoutwidth, height, top, margin, font-size…
paintcolor, background, shadow, outline…
compositetransform, opacity, filter (on a layer)
The per-frame pipeline. A change enters at the leftmost stage it invalidates and everything to its right must run again.
Kind of changeExamplesStages that runCost
Geometrywidth, height, top/left, margin, font-size, adding a DOM nodelayout → paint → compositeHighest. Layout may cascade to the whole document.
Appearancecolor, background, box-shadow, visibilitypaint → compositeMedium. Proportional to the painted area.
Compositor-onlytransform, opacity (element already on its own layer)composite onlyLowest. Runs off the main thread; smooth even while JS is busy.

Forced synchronous layout and layout thrashing

The browser batches style writes and runs layout once per frame, lazily. But if your code reads a geometric value (offsetHeight, getBoundingClientRect(), scrollTop) after a write, the browser must run layout right now to give a correct answer. Alternating reads and writes in a loop forces a full layout per iteration: that is layout thrashing, and it is how a list of 200 items takes 400 ms to resize.

thrash.js
for (const el of items) {
  const h = el.parentElement.offsetHeight   // read → forces layout
  el.style.height = h / 2 + 'px'            // write → invalidates layout
}
// 200 items → 200 synchronous layouts inside one task
batched.js
const heights = items.map(el => el.parentElement.offsetHeight) // all reads
items.forEach((el, i) => {
  el.style.height = heights[i] / 2 + 'px'                       // all writes
})
// 1 layout for the reads, 1 more at the next frame for the writes

// or schedule writes for the next frame:
requestAnimationFrame(() => items.forEach(apply))

Reading a trace

The DevTools Performance panel shows the pipeline by name, so a recording answers "which step is slow" directly.

  1. 1
    Record a page load with CPU throttling at 4× and look at the main-thread flame chart. Parse HTML blocks split by Evaluate Script show parser-blocking scripts; a long gap before the first Paint with the network row showing a stylesheet still in flight is render-blocking CSS.
  2. 2
    Find the first Recalculate Style and Layout events. Their duration times the number of nodes they touched tells you whether the CSSOM or DOM is too big; the Coverage tab shows how much of each stylesheet was actually used.
  3. 3
    For jank, record an interaction or a scroll and look for purple Layout blocks inside a JavaScript task with a red triangle: that is a forced synchronous layout, and the call stack points at the read that caused it.
  4. 4
    Turn on Paint flashing and Layer borders in the Rendering drawer: green flashes on every scroll mean something repaints that should be composited, and a page with hundreds of layers is paying for will-change everywhere.
  5. 5
    In production, watch LCP and INP with field data; the trace is how you reproduce what the field numbers point at.

Pitfalls

  • A synchronous script in the head

    Analytics, a tag manager, a polyfill: each one stops the parser until it has downloaded from a third-party origin and run, and it cannot run until every stylesheet above it is ready. Nothing paints meanwhile. defer for your own code, async for independent third parties, and nothing synchronous unless it must run before the DOM exists.

  • CSS @import chains

    @import inside a stylesheet is only discovered after that stylesheet downloads and parses, so each level is a serial round trip during which rendering is blocked. Bundle them, or use multiple <link> tags, which the preload scanner fetches in parallel.

  • One giant stylesheet for the whole site

    Unused CSS is still downloaded and parsed into the CSSOM before the first paint, and every style recalculation matches against it. Split per route, inline the critical part, load the rest non-blocking, and let the Coverage tab tell you how much is dead.

  • Animating layout properties

    A left: 0 → 300px transition runs layout and paint on every frame on the main thread, so it stutters whenever JavaScript is busy.transform: translateX(300px) runs on the compositor and keeps going even during a long task. The same applies to height accordions versus scaleY or a max-height on a clipped inner element.

  • Reading layout inside a loop or a scroll handler

    el.offsetTop in a scroll handler after any style write forces a layout per event, sixty times a second. Read once, cache, write in requestAnimationFrame, or use IntersectionObserver and ResizeObserver, which deliver geometry without forcing it.

Interview questions

Q1The HTML bytes have arrived. What does the browser do until the first pixel appears?

It tokenises the HTML into the DOM incrementally, kicking off downloads for stylesheets, scripts and images as it finds them. Stylesheets are parsed into the CSSOM; the browser will not paint until that is complete. Synchronous scripts pause the parser and wait for preceding CSS. When the DOM and CSSOM are ready they are combined into the render tree, layout computes every box's geometry, paint records the drawing commands into layers, and the compositor puts the layers on screen. The first pixel is gated by the HTML, all render-blocking CSS and any synchronous scripts before the content.

Q2Why does CSS block rendering while JavaScript blocks parsing?

The cascade means any later rule can override an earlier one, so painting with a partial CSSOM would show wrong styles and then reflow; the browser prefers a blank screen to a flash of unstyled content, but it can keep parsing HTML meanwhile. A script, on the other hand, can insert into or query the DOM at the point where it appears, so the parser cannot safely proceed past it until it has run, and it may read computed styles, so it also waits for the CSS above it.

Q3async versus defer versus type="module": when do you use which?

Both async and defer download in parallel with parsing. Async runs the moment it arrives, in any order, interrupting parsing briefly, which suits independent third-party scripts. Defer runs after parsing finishes, in document order, before DOMContentLoaded, which is right for application code that needs the DOM and other scripts. Modules are deferred by default and fetch their import graph, so a module script behaves like defer unless you add async.

Q4What is the difference between a reflow and a repaint, and which properties cause each?

Reflow is layout: recomputing geometry, triggered by anything that changes size or position such as width, height, margins, font-size or adding nodes, and it can cascade through the document. Repaint is regenerating pixels for an area without changing geometry, triggered by color, background, shadows or visibility. Every reflow causes a repaint; a repaint does not cause a reflow. Transform and opacity changes on a composited layer cause neither.

Q5Why is animating transform smoother than animating left?

Left is a layout property, so each frame recomputes geometry and repaints on the main thread, competing with JavaScript; if a task runs long, frames are dropped. Transform on an element with its own layer changes only how the compositor places an already-painted texture, which runs on the compositor thread and GPU, so the animation continues at full frame rate even while the main thread is blocked.

Q6What is layout thrashing, and how would you fix it in a component that resizes many items?

It is forcing the browser to run layout repeatedly inside one task by alternating style writes with geometry reads, since each read must return an up-to-date value. The fix is to separate the phases: read all the measurements first, then perform all the writes, or batch writes into a requestAnimationFrame callback. Libraries like FastDOM do exactly that, and ResizeObserver delivers sizes without a forced layout.

Q7Walk me through reducing render-blocking CSS on a page with a 200 KB stylesheet.

Measure with the Coverage tab to see how much is used on that route, usually under 20 percent. Extract the rules needed for above-the-fold content into an inline style block of roughly 10 to 15 KB. Load the rest with a preload that switches to a stylesheet on load, or split it per route so each page only fetches its own. Make print and rarely used media non-blocking with a media attribute, remove @import chains, and verify with a trace that the first paint no longer waits on the network.

Key takeaways
  • Bytes → DOM and CSSOM → render tree → layout → paint → composite. First paint waits for HTML, all render-blocking CSS and every synchronous script before the content.
  • CSS blocks rendering, synchronous scripts block parsing, and scripts wait for the CSS above them. Inline critical CSS, load the rest non-blocking, defer your code, async third parties.
  • After first paint, a change re-enters the pipeline at the stage it invalidates: geometry costs layout + paint + composite, appearance costs paint + composite, transform and opacity cost composite only.
  • Reading geometry after writing style forces a synchronous layout; in a loop that is layout thrashing. Batch reads, then writes, or use observers.
  • Fonts are discovered late; preload the file and choose a font-display that does not shift layout.
  • The Performance panel names the stages; Coverage shows dead CSS; paint flashing and layer borders show what repaints and what is over-promoted.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free