Lesson 02 / 27
Critical Rendering Path
The resources first paint has to wait for; the distinction between render-blocking CSS and parser-blocking scripts, computing the round-trip count, and the markup patterns that remove blocking.
Contents
The previous lesson defined the metrics: if largest contentful paint happens late, there is a problem. The delay can have three sources — the server producing the response, the response being transported, and the resources the browser has to wait for before it can paint anything. The third is entirely under the developer’s control.
This lesson defines that wait. The critical rendering path is the chain of work that must complete before first paint can happen. The chain’s length can be measured and shortened.
Two Different Kinds of Blocking
The browser parses the document top to bottom and requests the resources it encounters along the way. Two kinds of resource stop this flow, and they stop it differently.
Render-blocking CSS. Until an external stylesheet gets downloaded and resolved, the browser paints nothing. The reason is the cascade: by the rule the Visual Presentation with CSS course establishes, an element’s computed value can change because of a declaration in a file that has not arrived yet. Painting with incomplete style would mean a page that looks completely different seconds later. So the browser waits. Parsing the document continues — a stylesheet does not stop parsing — but painting stops.
Parser-blocking script. A script written directly into the document, carrying
neither defer nor async, stops parsing until it has downloaded and run. The reason is
that the script can modify the document: it can produce or delete the content that
follows it, so the browser cannot continue. Because parsing stops, nothing after it can
get discovered; painting cannot happen either.
The two kinds of blocking compound. A stylesheet that comes before a script also delays the script’s execution: because the script might read computed values, the browser has to wait for the stylesheet to resolve. A single ordering decision thereby ties three jobs together.
Three Variables
The wait’s length depends on three separate quantities, and separating them determines which optimization will actually help.
Byte count is the part that depends on bandwidth. Compression and dead-code elimination affect this.
Round-trip count is the part that depends on latency. When data gets sent over a connection, the congestion window starts small and grows with each round trip; a large file therefore does not arrive in a single round trip. This behavior, defined in the Network Models and Protocols course, produces the following performance consequence: the first bytes are cheap, the later ones are expensive.
Chain depth, in turn, depends on discovery order. If a resource’s existence only becomes known after another resource has downloaded, the two cannot be requested in parallel; their durations add up.
The following model computes all three together.
// critical-path.mjs — a computation modeling how many round trips first paint costs const MSS = 1460; // bytes: the payload one segment carries const INITIAL_WINDOW = 10; // segments: how many segments can be sent in the first round // The connection carries the congestion window across rounds; the window doubles each round. function connection() { let window = INITIAL_WINDOW; return { get window() { return window; }, // One stage: resources requested concurrently share the same window. stage(bytes) { let remaining = bytes.reduce((a, b) => a + b, 0); let round = 0; do { remaining -= window * MSS; window *= 2; round += 1; } while (remaining > 0); return round; }, }; } function firstPaint(scenario) { const b = connection(); let round = scenario.connectionSetup; const breakdown = [["connection setup", scenario.connectionSetup, "-"]]; for (const stage of scenario.stages) { const previousWindow = b.window; const t = b.stage(stage.bytes); round += t; breakdown.push([stage.name, t, `${previousWindow} -> ${b.window} segments`]); } return { round, breakdown }; } // North Slope Measurement Station — the station list page. // Connection setup: name resolution 1 + transport handshake 1 + secure handshake 1 = 3 rounds. const scenarios = [ { name: "A: blocking style and script up front", connectionSetup: 3, stages: [ { name: "HTML body", bytes: [18_400] }, { name: "blocking style + script", bytes: [62_000, 141_000] }, { name: "style discovered by the script", bytes: [24_000] }, ], }, { name: "B: script deferred", connectionSetup: 3, stages: [ { name: "HTML body", bytes: [18_400] }, { name: "blocking style", bytes: [62_000] }, ], }, { name: "C: critical style inlined", connectionSetup: 3, stages: [ { name: "HTML body (+ inlined style)", bytes: [18_400 + 9_800] }, ], }, ]; console.log("--- slow start: total bytes sendable by the end of round k ---"); let accumulated = 0; let window = INITIAL_WINDOW; for (let k = 1; k <= 4; k++) { accumulated += window * MSS; console.log(`round ${k} window ${String(window).padStart(3)} segments total ${String(accumulated).padStart(7)} bytes`); window *= 2; } console.log("\n--- round-trip breakdown to first paint ---"); const results = []; for (const s of scenarios) { const r = firstPaint(s); results.push({ name: s.name, round: r.round }); console.log(`\n${s.name}`); for (const [name, t, p] of r.breakdown) { console.log(` ${name.padEnd(30)} ${t} rounds ${p}`); } console.log(` ${"TOTAL".padEnd(30)} ${r.round} rounds`); } console.log("\n--- comparing the scenarios ---"); const baseline = results[0].round; for (const s of results) { console.log(`${s.name.padEnd(34)} ${String(s.round).padStart(2)} rounds ratio to A: ${(s.round / baseline).toFixed(2)}`); }
--- slow start: total bytes sendable by the end of round k --- round 1 window 10 segments total 14600 bytes round 2 window 20 segments total 43800 bytes round 3 window 40 segments total 102200 bytes round 4 window 80 segments total 219000 bytes --- round-trip breakdown to first paint --- A: blocking style and script up front connection setup 3 rounds - HTML body 2 rounds 10 -> 40 segments blocking style + script 3 rounds 40 -> 320 segments style discovered by the script 1 rounds 320 -> 640 segments TOTAL 9 rounds B: script deferred connection setup 3 rounds - HTML body 2 rounds 10 -> 40 segments blocking style 2 rounds 40 -> 160 segments TOTAL 7 rounds C: critical style inlined connection setup 3 rounds - HTML body (+ inlined style) 2 rounds 10 -> 40 segments TOTAL 5 rounds --- comparing the scenarios --- A: blocking style and script up front 9 rounds ratio to A: 1.00 B: script deferred 7 rounds ratio to A: 0.78 C: critical style inlined 5 rounds ratio to A: 0.56
The first table shows the congestion window growing: in the first round a little over fourteen kilobytes of data can be sent, and by the end of the fourth round that exceeds two hundred kilobytes. This number is not a limit but a starting assumption; the initial window depends on configuration, and the model exposes the value as a constant up front.
The three scenarios build the same page with three different markups. In scenario A, the blocking style and script sit at the top of the document, and a second stylesheet gets discovered only after the script has downloaded. In scenario B, the script gets deferred; both its own bytes and the chain it discovers drop out of the critical path. In scenario C, the first screen’s style gets inlined into the document and no external request remains.
The result reads through ratios: scenario B waits seventy-eight percent of A’s time, scenario C fifty-six percent. An absolute duration cannot be given, because a round’s duration depends on the network — but the round count follows from the markup itself and is comparable.
The model simplifies reality at two points. It assumes that concurrent requests within a stage share the same window, and it does not account for the window shrinking after idle time. Both push the result in favor of A, not B and C; the real difference, in other words, is not smaller than what the model shows but larger.
Removing the Blocking
The ways of shortening the critical path correspond to the two kinds of blocking.
Taking the script out of the critical path. The defer attribute lets the script
download in parallel with parsing and run, in write order, only after document parsing
finishes. async also downloads in parallel but runs the moment it arrives; there is no
order guarantee, and running it still interrupts parsing at that moment. defer suits
scripts that depend on each other; async suits scripts that are independent and
self-contained. Scripts loaded as modules behave as deferred by default.
Making a stylesheet conditional. When the media attribute gets written on a
stylesheet link, the file does not block painting if the condition does not currently
hold. Print stylesheets and styles that only apply on wide screens get taken out of the
critical path this way. The file still downloads, but at low priority and without
holding up painting.
Inlining critical CSS. The declarations sufficient to paint the first screen get written directly into the document; the remaining style loads through a non-blocking path. This is the source of scenario C’s gain, and it has a cost: inlined style cannot be cached separately, arrives again with every page request, and enlarges the document. The decision depends on the repeat-visit rate — a gain on a page dominated by first visits is a loss on a panel opened every day.
Moving a late-discovered resource earlier. If a resource only gets discovered after a
stylesheet resolves or a script runs, it can be requested at the top of the document with
a preload declaration. This declaration is a correction, not an optimization: it should
not get used when a direct way to shorten the chain exists, because written incorrectly
it makes a resource download twice or takes bandwidth away from a resource that is
genuinely critical.
The Discovery Chain
Scenario A’s fourth line — “style discovered by the script” — alone produces more than one round’s cost, because that round can only start after the script has downloaded and run.
The browser’s preload scanner skims the document quickly before parsing it and requests the resources it sees ahead of time. The one thing the preload scanner cannot see is resources not written in the document: links inserted by a script, other stylesheets called from within a stylesheet, addresses determined only after a configuration file gets read.
This is why the most effective step in critical-path optimization is often not reducing bytes but flattening the chain: making every critical resource visible in the document’s first bytes.
The Station List Page’s Document Head
<head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>North Slope Measurement Station — Stations</title> <!-- first screen's style: inlined, no external request --> <style> .station-list { display: grid; gap: 12px; } .station-list > li { min-height: 72px; } </style> <!-- remaining style: does not block painting, applies once it has downloaded --> <link rel="stylesheet" href="/assets/station.9f2c1a.css" media="print" onload="this.media='all'"> <noscript><link rel="stylesheet" href="/assets/station.9f2c1a.css"></noscript> <!-- print style never blocks painting --> <link rel="stylesheet" href="/assets/print.3ce8b0.css" media="print"> <!-- application script: downloads in parallel with parsing, runs in order once parsing finishes --> <script src="/assets/entry.4a1c.js" defer></script> </head>
The second link is a markup trick and needs explaining: a stylesheet written with
media="print" does not block painting, and once the file has downloaded, the onload
handler sets the media to all, applying the style. The second link inside noscript
makes sure the style still arrives when the script does not run. The trick exists because
there is no direct “blocking” declaration available for a stylesheet link.
The min-height declaration in the inlined style is for stability, not performance: the
list rows take their height before the data arrives, so the layout shift computed in the
previous lesson does not happen. Two goals meet in the same declaration.
Summary
- The critical rendering path is the chain of work first paint has to wait for; render-blocking CSS stops painting, and a parser-blocking script stops both parsing and painting.
- The wait’s three components get handled separately: byte count depends on bandwidth, round-trip count on latency, and chain depth on discovery order.
- Because the congestion window grows with rounds, the first bytes are cheap; taking a resource out of the critical path can give a much larger gain than shrinking it.
defer,async, andmediadeclarations remove blocking; inlining critical style erases the external request entirely but gives up cacheability.- The preload scanner sees only resources written in the document; flattening the chain is often more effective than reducing bytes.
Next Step
When the critical path shortens, first paint moves earlier, but on most pages largest contentful paint is not a block of text — it is an image. The card images on the station list and the station photo on the measurement detail page carry far more bytes than the document itself; font files, in turn, determine when the text becomes readable. Both have their own decisions about size, format, and loading. The next lesson takes up these decisions and computes which candidate file downloads under which condition.
To keep your progress and take notes, Log in
My notes
Log in to take notes.