Skip to content
academia.sh

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, and media declarations 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.

Start typing to search.

↑↓ Esc navigate · open · close