12 min read

The Critical Rendering Path

The first paint is a chain of round trips. Make the chain shorter and the page appears sooner, whatever the framework.


The story

A product launch page scores well on a fast office laptop. On a mid-range Android phone over 4G, it shows a white screen for 3.8 seconds, and the hero image appears at 5.1 seconds.

The server responds in 180 ms. The JavaScript bundle is reasonably sized. Nothing is "slow". So the team opens the Network panel with mobile throttling and reads the waterfall from the top:

  1. The HTML arrives.
  2. The browser finds <link rel="stylesheet" href="/main.css"> and fetches it. Rendering is now blocked until it arrives.
  3. main.css starts with @import url("https://fonts.example.com/css?family=Brand"). The browser can only discover that after downloading and parsing main.css, so it's a second round trip, to a new origin: DNS lookup, TCP connect, TLS handshake, then the request.
  4. That font CSS references the font files. A third round trip. The headline text stays invisible until the font arrives.
  5. The hero image is a CSS background-image on a <div>. The browser only learns it exists once the CSS is parsed and the element is laid out. It's requested last, at low priority.

No single request was slow. The page was slow because each resource could only be discovered after the previous one arrived. Four round trips at roughly 300 ms each on mobile, plus a new connection, is most of the 3.8 seconds.

The fix changed no application code:

html
<link rel="preconnect" href="https://fonts.example.com" crossorigin>
<link rel="preload" href="/fonts/brand.woff2" as="font" type="font/woff2" crossorigin>
<style>/* ~10 KB of critical CSS, inlined */</style>
<link rel="stylesheet" href="/main.css" media="print" onload="this.media='all'">
<img src="/hero.avif" fetchpriority="high" alt="…" width="1200" height="600">

First paint dropped to 1.4 s and the hero (the LCP element) to 1.9 s. That's what the critical rendering path is about: not making requests faster, but making fewer of them wait on each other.


Part 1: From bytes to pixels

When the HTML arrives, the browser runs a pipeline. You've probably seen the boxes before. What matters is which arrows block.

Three facts explain most of the waterfall:

  1. The DOM is built incrementally. The parser can work on the first part of the HTML while the rest is still downloading. Streaming HTML helps.
  2. CSS is render-blocking. The browser won't paint until it has the CSS that applies to the page, because painting unstyled content and then restyling it would flash (a "flash of unstyled content"). CSS doesn't block parsing, but it blocks rendering.
  3. Classic <script> tags are parser-blocking. The parser stops, downloads and runs the script, then continues, because the script might call document.write or read the DOM above it. And if CSS is still loading, the script waits for it too, because it might read computed styles. So a script placed after a stylesheet is blocked by both.

What "critical" means

Three numbers describe a critical path:

MeasureMeaningLower it by
Critical resourcesResources that block the first renderRemoving, deferring or inlining them
Critical path lengthRound trips in the longest dependency chainFlattening chains: preload, preconnect, no @import
Critical bytesTotal bytes needed before first renderMinifying, splitting critical CSS, compressing

Path length usually matters most on mobile, because every round trip costs the full network latency, often 100–300 ms. Ten fewer KB rarely matters as much as one fewer round trip.


Part 2: Scripts — the three loading modes

ModeBlocks parsingRuns whenOrder keptUse for
ClassicYesImmediatelyYesAlmost nothing in the <head> today
asyncOnly while runningWhen downloadedNoIndependent third-party scripts (analytics)
deferNoAfter parsingYesYour app code
type="module"No (deferred by default)After parsingYesModern app code (async also works on modules)

Rule of thumb: nothing in the <head> should be a classic script unless it has to run before first paint, such as a tiny inline snippet that sets the theme class to avoid a dark-mode flash.


Part 3: The preload scanner, and how to hide things from it

While the main parser is blocked on a script, browsers run a second, lightweight preload scanner over the raw HTML. It finds <link>, <script src> and <img src> tags ahead of the parser and starts downloading them early. This is one of the biggest performance features browsers have, and it works without you doing anything, unless your markup hides resources from it.

Everything in the right-hand column is discovered late: after a stylesheet is parsed, or after a script runs. That's the story's problem in one picture. The usual fixes:

  • Hero image as a real <img>, not a CSS background. If it must be a background, add <link rel="preload" as="image">.
  • No @import in CSS. Use <link> tags, or let your bundler inline the import.
  • Preload the one or two critical fonts.
  • Server-render the LCP content. If the hero image only exists after client-side JS fetches data and renders, it can't be found until all of that has happened.
  • Never lazy-load the LCP image. loading="lazy" on the hero tells the browser to wait until layout to decide whether to fetch it.

Part 4: Resource hints — telling the browser what you know

The browser only knows what it has parsed. You know more. Hints let you share it:

HintWhat it doesUse when
preconnectDNS + TCP + TLS to an origin, before any requestA critical resource lives on another origin (fonts, an image CDN, your API). Limit to 2–4.
dns-prefetchDNS onlyCheap fallback for origins you'll probably use
preloadFetch this specific resource now, at the priority its as impliesA late-discovered critical resource: font, hero image, critical CSS chunk
modulepreloadPreload a JS module and its parsingCritical ES module chunks
prefetchFetch at idle priority for a future navigationThe next page the user will probably visit
fetchpriority="high"/"low"Adjust the priority of one requestRaise the LCP image. Lower below-the-fold images or non-critical scripts.

Two warnings:

  • Preloading everything is the same as preloading nothing. Each preload competes for the same bandwidth. Preload what's both critical and discovered late.
  • Fonts need crossorigin on the preload, even on the same origin, or the browser fetches the font twice.

Priorities

Browsers assign every request a priority: CSS and fonts in the head are high, images start low and are bumped up if they turn out to be in the viewport, and async scripts are low. The DevTools Network panel's "Priority" column shows this. If your LCP image says "Low", that's your first bug. fetchpriority="high" fixes it directly.

103 Early Hints

The server is often busy for hundreds of milliseconds (querying a database, rendering) before it can send the HTML. With 103 Early Hints, it immediately sends an interim response listing preconnect and preload links. The browser starts those fetches during the server's think time. Chrome supports it, and CDNs like Cloudflare can generate the hints from previous responses.


Part 5: Fonts

Web fonts are a critical-path problem with a visible symptom: invisible or jumping text.

  • font-display: swap shows text immediately in a fallback font. The swap can shift layout (CLS) if the fallback has different metrics.
  • Match the fallback's metrics with size-adjust, ascent-override and descent-override on a local fallback @font-face, so the swap barely moves anything. Next.js's next/font does this automatically.
  • font-display: optional is the most stable choice for body text: a fast connection gets the web font, a slow one keeps the fallback for that page view.
  • Self-host and subset: one WOFF2 file per weight, Latin-only if that's what you need, often 15–30 KB.

Part 6: CSS — critical, async and split

The goal: the first render needs only the CSS for what's above the fold, and that CSS should arrive with the HTML.

  • Inline critical CSS in a <style> tag in the head. The first TCP round trip carries about 14 KB of compressed data (the initial congestion window of 10 packets), so critical HTML + CSS that fits in that can render after a single round trip.
  • Load the rest without blocking: the media="print" onload trick from the story, or a preload with as="style" that swaps itself to a stylesheet.
  • Split CSS by route. A 300 KB global stylesheet blocks every page for styles most pages never use. Most bundlers and CSS-in-JS setups can split per route.
  • media attributes make stylesheets non-blocking for media that doesn't match: <link rel="stylesheet" href="print.css" media="print"> doesn't block screen rendering.

Part 7: The server's side of the path

Everything above assumes HTML arrives quickly. Two server-side techniques shorten the path further:

  • Stream the HTML. Send the <head> immediately, so CSS and preloads start downloading while the server is still querying data. React's streaming SSR and Next.js's App Router do this with Suspense boundaries.
  • Server-render the above-the-fold content, so the LCP element is in the initial HTML and not waiting for a client-side fetch. LCP splits into four parts: time to first byte, resource load delay (how long before the LCP resource starts downloading), resource load time, and render delay. FE-04 walks through diagnosing each. Most of this chapter attacks the load delay.

Numbers to remember

NumberWhy it matters
100–300 msA round trip on a typical mobile connection. Each extra link in the chain costs this.
~3 round tripsA new HTTPS connection: DNS + TCP + TLS before the first byte of the request (fewer with TLS 1.3 / HTTP/3)
~14 KBWhat the first TCP round trip can carry. Critical HTML + inline CSS that fits renders sooner.
2.5 s"Good" LCP at the 75th percentile
2–4Sensible number of preconnects. More compete with each other.
15–30 KBA subsetted WOFF2 font weight

The DevTools workflow

  1. Throttle realistically. Network: "Fast 4G" or "Slow 4G". CPU: 4× slowdown. Disable cache.
  2. Network panel → enable the "Priority" column. Reload. Read the waterfall top to bottom and ask for each request: what made the browser discover this, and when? Hover the Initiator column to see the chain.
  3. Look for staircases: a request that starts only when the one above it finishes is a dependency chain. Each step is a round trip you might remove.
  4. Performance panel → record a reload. The Network track shows render-blocking requests (marked in the request details), and the LCP marker shows when and what the LCP element was. The Insights panel calls out LCP request discovery problems and render-blocking requests.
  5. Lighthouse lists "Render-blocking resources" and "Preload LCP image" opportunities. Treat it as a pointer, then confirm in the waterfall.
  6. Check field data (CrUX, your RUM from FE-08). The lab shows the mechanism, the field shows whether real users are affected.

Cheat sheet

ProblemFix
White screen until a big CSS file loadsInline critical CSS, load the rest async, split CSS per route
@import in CSSReplace with <link>s or let the bundler inline it
Classic <script> in the headdefer or type="module". async for independent third parties.
Hero image discovered lateReal <img> in the HTML, fetchpriority="high", never loading="lazy"
Font on another originpreconnect (with crossorigin) or self-host. Preload the one critical weight.
Invisible textfont-display: swap or optional, with a metric-matched fallback
Layout jump when the font loadssize-adjust / ascent-override on the fallback, or next/font
LCP element rendered by client JSServer-render it, or at least preload its image
Server think time before HTMLStream the head early. 103 Early Hints.
Too many preloadsKeep only critical + late-discovered resources

Say it in the interview

"I'd read the waterfall under mobile throttling and look for dependency chains, because on mobile each round trip is 100–300 ms. I'd make the hero a real img with fetchpriority high so the preload scanner finds it, inline the critical CSS, remove @import chains, preconnect to the font origin and preload the one critical font with a metric-matched fallback. Scripts become defer or modules, and I'd stream the head so those downloads start while the server is still fetching data."


Go deeper