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.
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:
<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
- 1Parse 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>withoutasync/deferstops the parser until it has run, because the script may calldocument.writeor query the DOM built so far. - 2Parse 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.
- 3Render tree. Walk the DOM, skip nodes that do not render (
head,display: none), attach each visible node's computed style.visibility: hiddenstays in the tree because it still takes up space. - 4Layout. 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.
- 5Paint. 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.
- 6Composite. Upload layers as textures and combine them, applying
transformandopacity, 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 form | Download | Runs | Blocks parser | Order kept |
|---|---|---|---|---|
<script src> | Immediately (parser pauses) | As soon as downloaded, after preceding CSS | Yes, for download and execution | Yes |
async | In parallel with parsing | As soon as downloaded, whenever that is | Only during execution | No: whichever arrives first runs first |
defer | In parallel with parsing | After parsing completes, before DOMContentLoaded | No | Yes, in document order |
type="module" | In parallel, plus its imports | Deferred by default (async if marked) | No | Yes, in document order |
<link rel="stylesheet"> | Immediately, high priority | n/a | No, but blocks rendering and scripts below it | Cascade order |
media="print" stylesheet | Low priority | n/a | No, and does not block rendering | n/a |
<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 --><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.
| Kind of change | Examples | Stages that run | Cost |
|---|---|---|---|
| Geometry | width, height, top/left, margin, font-size, adding a DOM node | layout → paint → composite | Highest. Layout may cascade to the whole document. |
| Appearance | color, background, box-shadow, visibility | paint → composite | Medium. Proportional to the painted area. |
| Compositor-only | transform, opacity (element already on its own layer) | composite only | Lowest. 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.
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 taskconst 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.
- 1Record 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.
- 2Find 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.
- 3For 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.
- 4Turn 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-changeeverywhere. - 5In 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.
deferfor your own code,asyncfor independent third parties, and nothing synchronous unless it must run before the DOM exists. - CSS @import chains
@importinside 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 → 300pxtransition 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 toheightaccordions versusscaleYor a max-height on a clipped inner element. - Reading layout inside a loop or a scroll handler
el.offsetTopin a scroll handler after any style write forces a layout per event, sixty times a second. Read once, cache, write inrequestAnimationFrame, or useIntersectionObserverandResizeObserver, 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.
- 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,
deferyour code,asyncthird 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-displaythat 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.