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:
- The HTML arrives.
- The browser finds
<link rel="stylesheet" href="/main.css">and fetches it. Rendering is now blocked until it arrives. main.cssstarts with@import url("https://fonts.example.com/css?family=Brand"). The browser can only discover that after downloading and parsingmain.css, so it's a second round trip, to a new origin: DNS lookup, TCP connect, TLS handshake, then the request.- That font CSS references the font files. A third round trip. The headline text stays invisible until the font arrives.
- The hero image is a CSS
background-imageon 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:
<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:
- 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.
- 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.
- Classic
<script>tags are parser-blocking. The parser stops, downloads and runs the script, then continues, because the script might calldocument.writeor 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:
| Measure | Meaning | Lower it by |
|---|---|---|
| Critical resources | Resources that block the first render | Removing, deferring or inlining them |
| Critical path length | Round trips in the longest dependency chain | Flattening chains: preload, preconnect, no @import |
| Critical bytes | Total bytes needed before first render | Minifying, 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
| Mode | Blocks parsing | Runs when | Order kept | Use for |
|---|---|---|---|---|
| Classic | Yes | Immediately | Yes | Almost nothing in the <head> today |
async | Only while running | When downloaded | No | Independent third-party scripts (analytics) |
defer | No | After parsing | Yes | Your app code |
type="module" | No (deferred by default) | After parsing | Yes | Modern 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
@importin 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:
| Hint | What it does | Use when |
|---|---|---|
preconnect | DNS + TCP + TLS to an origin, before any request | A critical resource lives on another origin (fonts, an image CDN, your API). Limit to 2–4. |
dns-prefetch | DNS only | Cheap fallback for origins you'll probably use |
preload | Fetch this specific resource now, at the priority its as implies | A late-discovered critical resource: font, hero image, critical CSS chunk |
modulepreload | Preload a JS module and its parsing | Critical ES module chunks |
prefetch | Fetch at idle priority for a future navigation | The next page the user will probably visit |
fetchpriority="high"/"low" | Adjust the priority of one request | Raise 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
crossoriginon 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: swapshows 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-overrideanddescent-overrideon a local fallback@font-face, so the swap barely moves anything. Next.js'snext/fontdoes this automatically. font-display: optionalis 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" onloadtrick from the story, or apreloadwithas="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.
mediaattributes 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
| Number | Why it matters |
|---|---|
| 100–300 ms | A round trip on a typical mobile connection. Each extra link in the chain costs this. |
| ~3 round trips | A new HTTPS connection: DNS + TCP + TLS before the first byte of the request (fewer with TLS 1.3 / HTTP/3) |
| ~14 KB | What 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–4 | Sensible number of preconnects. More compete with each other. |
| 15–30 KB | A subsetted WOFF2 font weight |
The DevTools workflow
- Throttle realistically. Network: "Fast 4G" or "Slow 4G". CPU: 4× slowdown. Disable cache.
- 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.
- 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.
- 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.
- Lighthouse lists "Render-blocking resources" and "Preload LCP image" opportunities. Treat it as a pointer, then confirm in the waterfall.
- Check field data (CrUX, your RUM from FE-08). The lab shows the mechanism, the field shows whether real users are affected.
Cheat sheet
| Problem | Fix |
|---|---|
| White screen until a big CSS file loads | Inline critical CSS, load the rest async, split CSS per route |
@import in CSS | Replace with <link>s or let the bundler inline it |
Classic <script> in the head | defer or type="module". async for independent third parties. |
| Hero image discovered late | Real <img> in the HTML, fetchpriority="high", never loading="lazy" |
| Font on another origin | preconnect (with crossorigin) or self-host. Preload the one critical weight. |
| Invisible text | font-display: swap or optional, with a metric-matched fallback |
| Layout jump when the font loads | size-adjust / ascent-override on the fallback, or next/font |
| LCP element rendered by client JS | Server-render it, or at least preload its image |
| Server think time before HTML | Stream the head early. 103 Early Hints. |
| Too many preloads | Keep 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
- FE-04 · Web Vitals: From Metrics to Fixes: diagnosing slow LCP, CLS and INP cause by cause.
- FE-03 · Bundle and Loading Performance: what's inside the JS you're deferring.
- FE-05 · Network and Caching Strategy: making the second visit fast.
- Frontend First Principles · Book 3: the rendering pipeline from the ground up.
- Next: The Main Thread and Scheduling: what happens after the first paint.