The critical rendering path
Everything the browser must fetch and compute before the first pixels: HTML, render-blocking CSS, parser-blocking scripts, then style, layout, paint, and composite, and how to count, measure, and shorten it.
A blank white screen for three seconds
A team ships a new landing page. The server answers in 80 ms, the HTML is only 18 KB, and the lab score on a fast laptop looks fine. Then the support tickets arrive: on phones, people stare at a white screen for about three seconds before anything shows up. Some leave before the page ever appears.
Nothing is broken: the server is fast, and every file loads with status 200. The problem is the order of the work. Before the browser can paint a single pixel, it must have the HTML, it must have every stylesheet in the head, and it must have downloaded and run every plain script tag it met on the way. Each of those is a request, and on a phone each request costs round trips.
The critical rendering path is that chain: the minimum set of steps and resources the browser needs between receiving the first byte of HTML and drawing the first pixels. A short path means a fast first paint. A long one means a blank screen, no matter how fast your server is.
Why a frontend engineer must own it
The backend controls TTFB, the time to the first byte of the response. Almost everything after that first byte is decided by the markup you ship: which files you link, in which order, with which attributes. One misplaced script tag in the head can add a full second on a slow network, and no server change will fix it.
Two metrics measure the result: FCP (First Contentful Paint) is when the browser first paints any text or image, and LCP (Largest Contentful Paint) is when the biggest element in the viewport, usually a hero image or a headline, is painted. Google treats an LCP of 2.5 seconds or less at the 75th percentile of real visits as good. The critical rendering path sets the floor for both: nothing paints before it finishes.
- What blocks the first paint: the HTML itself, CSS in the
head(render-blocking), and classic scripts withoutasyncordefer(parser-blocking). - What does not: images,
asyncanddeferscripts, fonts (mostly), stylesheets with amediaquery that does not match, and anything fetched later by JavaScript. - What the browser does after it has the files: build the DOM and CSSOM, combine them into a render tree, compute style, lay out boxes, paint pixels, and composite layers on the GPU.
This lesson walks the whole path: how the browser reads HTML as it streams in, why CSS blocks rendering and scripts block parsing, what the render tree is, which style changes cost a layout and which are almost free, how to count the path in round trips and bytes, how to measure it in real browsers, and how to make it short. It ends with how to use all of this in a system design interview, where the first-load story is one of the most common follow-up questions.
Check your understanding · Exercise 1
A page's head links app.css, a print.css with media="print", an analytics.js with async, and a plain vendor.js script. The body starts with a hero image. Which of them must finish before the first paint? Select all that apply.
The mental model: a pipeline with two gates
Think of the browser as a small factory. Raw material (bytes from the network) comes in at one end. Finished product (pixels on the glass) comes out at the other. In between there is a line of stations, and each station needs the output of the one before it.
- Bytes to characters. The browser decodes the bytes using the character set, usually UTF-8.
- Characters to tokens. The HTML tokenizer turns text like
<p class="lead">into start tags, attributes, text, and end tags. - Tokens to nodes to the DOM. The tree builder turns tokens into element nodes and links them as parents and children. The DOM (Document Object Model) is that tree, and it is also the API your JavaScript uses.
- CSS to the CSSOM. Each stylesheet is parsed into rules. The CSSOM (CSS Object Model) is the set of all those rules, in cascade order.
- DOM plus CSSOM to the render tree. The browser matches rules to elements and computes the final style of every visible element.
- Layout. It works out the size and position of every box, in pixels, for this viewport width.
- Paint. It records the drawing commands: fill this rectangle, draw this text, draw this image.
- Composite. It splits the page into layers, rasterizes them into pixels, and the compositor stacks them into the final frame on the GPU.
The two gates that stop the line
If the line just flowed, the page would appear as fast as bytes arrive. It does not, because two kinds of resources close a gate until they arrive.
| Question | Render-blocking | Parser-blocking |
|---|---|---|
| What closes the gate | A stylesheet in the head whose media matches | A classic script tag with no async, defer, or type="module" |
| What stops | Painting. Parsing goes on in the background. | Building the DOM. Nothing after the tag exists yet. |
| Why the browser does it | To avoid painting unstyled content and then repainting it (a flash of unstyled content) | The script may call document.write or read the DOM, so it must run exactly where it sits |
| How to open the gate | Inline the CSS needed for the first screen, load the rest later, or use media | Add defer, async, or type="module", or move the script out of the critical path |
The two gates interact. A classic script waits for every stylesheet above it before it runs, because the script might ask for a computed style such as getComputedStyle(el).width. So a slow stylesheet followed by a classic script blocks both painting and parsing. That combination is the most common cause of a long blank screen.
What makes a resource critical
A resource is on the critical path if the first paint cannot happen without it. That is a property of how you load it, not of the file itself. The same 40 KB stylesheet is critical when linked normally in the head, and not critical when linked with media="print" or loaded after the first paint.
So the critical rendering path has three numbers you can count and shrink:
- Critical resources: how many files block the first paint. The HTML is always one.
- Critical bytes: the total size of those files, after compression.
- Critical path length: the number of round trips needed to fetch them, given that some can only be discovered after others arrive (a font found inside a stylesheet is two levels deep).
Check your understanding · Exercise 2
The head has link rel="stylesheet" href="big.css" (takes 800 ms to arrive) followed by an inline classic script that sets a class on the html element. Which statements are true? Select all that apply.
Reading HTML: the streaming parser and the preload scanner
The browser does not wait for the whole HTML document. It parses HTML incrementally: as each network packet arrives, the tokenizer reads it and the tree builder adds nodes to the DOM. A 200 KB page that arrives over 400 ms can have its header in the DOM after the first 30 ms of data.
This is why the first bytes matter so much. Whatever is in the first chunk the server sends, the browser can start on at once: discover the stylesheet, open a connection to the image CDN, even paint a header if no gate is closed. Whatever the server holds back, the browser cannot act on.
Streaming the response
A server can send HTML in pieces with chunked transfer encoding (HTTP/1.1) or simply as several DATA frames (HTTP/2 and HTTP/3). A common pattern is to flush the head early: send the head, with its stylesheet links and preload hints, before the server has finished the database queries for the body. The browser starts fetching CSS while the server is still working.
app.get("/product/:id", async (req, res) => {
res.setHeader("Content-Type", "text/html; charset=utf-8");
// 1. Flush the head at once: the browser starts fetching CSS and fonts now.
res.write(`<!doctype html><html><head>
<link rel="stylesheet" href="/app.4f2c.css">
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
</head><body>`);
// 2. Then do the slow work and send the body.
const product = await db.products.find(req.params.id); // 300 ms
res.write(renderProduct(product));
res.end("</body></html>");
});
React's renderToPipeableStream, Next.js streaming with Suspense, and similar features in other frameworks do this for you, and go further: they send the page shell first and stream slower sections in later.
The preload scanner
When a classic script stops the main parser, the DOM stops growing. Older browsers also stopped downloading, so every script after the first one was discovered late. Every modern browser now runs a second, lightweight parser called the preload scanner (also called the speculative parser). It reads ahead through the raw HTML while the main parser is blocked and starts downloads for what it finds.
- What it finds:
script src,link rel="stylesheet",link rel="preload",img srcandsrcset,picturesources,video poster, and other URLs written directly in the markup. - What it cannot find: anything not in the HTML text. That means images in CSS (
background-image), fonts (referenced only from@font-face), files pulled in by CSS@import, scripts and images inserted by JavaScript, and everything a client-rendered app fetches after it boots. - What it does not decide: it only fetches. The main parser still builds the DOM and runs scripts in order.
The scanner is the reason one rule matters so much: put important resources in the HTML as real tags. A hero image set with img src starts downloading within milliseconds of the HTML arriving. The same image set as a CSS background waits for the stylesheet to download and parse, then for the render tree to show that the element is visible. A hero image injected by a React component waits for the whole JavaScript bundle to download and run.
Late discovery is also why @import in CSS is slow. The browser must download main.css, parse it, find @import url(theme.css), and only then request theme.css. That is a chain of two round trips where one would do. Use two link tags instead, so the scanner finds both at once.
Check your understanding · Exercise 3
Which of these will the preload scanner start downloading before the main parser reaches them? Select all that apply.
CSS and the CSSOM: why CSS blocks rendering
HTML can be shown a piece at a time. CSS cannot. The browser builds the DOM as bytes arrive, but it will not paint until it has every render-blocking stylesheet fully downloaded and parsed into the CSSOM. Here is why.
Why the CSSOM is all or nothing
CSS is a cascade: a rule at the end of a file can override a rule at the start. Take h1 { color: black } on line 10 and .hero h1 { color: white } on line 4,000. If the browser painted after reading line 10, the hero heading would show black on a dark background, then flip to white a moment later. Every element could change size, color, or position as more rules arrive.
Painting early would cause a flash of unstyled content (FOUC) followed by layout shifts. Browsers chose a blank screen instead: they hold the first paint until the CSSOM is complete. That is the right default for users, and it puts the job of keeping CSS small and early on you.
Which stylesheets block
- A
link rel="stylesheet"in theheadblocks rendering if itsmediamatches. Nomediaattribute meansall, so it always blocks. - A
styleelement blocks too, but it costs no request: its CSS is already in the HTML. - A stylesheet whose
mediadoes not match, such asmedia="print"ormedia="(min-width: 1200px)"on a phone, does not block. It still downloads, at low priority, so it is ready if the media starts matching. - A stylesheet added by JavaScript after the first paint does not block the first paint, but it can restyle the page when it lands.
- A stylesheet link inside the
body: in Chromium browsers it blocks the rendering of the content after it, but content before it can paint. This lets a page load the CSS for each section next to that section.
media attribute is the cheapest way to take a file off the critical path.Remember how the two gates interact, from the mental model chapter: a stylesheet also holds back any classic script below it, because the script might read styles. Keep scripts with no attributes out of the head entirely and that coupling goes away.
Size matters more than selectors
Engineers sometimes worry about slow selectors. Browsers match selectors right to left and cache aggressively, so on a normal page, style calculation for the first paint takes a few milliseconds. The real cost of CSS on the first load is bytes and round trips: a 200 KB stylesheet, of which the first screen uses 15 KB, still has to arrive in full before anything paints.
Chrome DevTools has a Coverage panel that shows, per file, how many bytes were used to render the page so far. On sites built with a large component library it is common to see most of the CSS unused on the first page. That unused part is still on the critical path.
Check your understanding · Exercise 4
On a 390 px wide phone, the head has a.css (no media), b.css with media="(min-width: 768px)", and c.css with media="print". Which statements are true? Select all that apply.
Scripts: classic, async, defer, and module
JavaScript can change the document while it is being parsed. An old API, document.write, inserts markup at the exact spot where the script sits. So when the parser meets a classic script tag, it must stop, download the file, wait for any stylesheets above it, run it, and only then continue. That is what parser-blocking means.
Attributes tell the browser that your script does not need that guarantee. Each one changes two things: when the file downloads relative to parsing, and when it runs.
| Behavior | No attribute | async | defer | type="module" |
|---|---|---|---|---|
| Downloads | In parallel (found by the scanner), but the parser waits for it | In parallel, parser keeps going | In parallel, parser keeps going | In parallel, plus every import it pulls in |
| Runs | At once, where it sits | As soon as it has downloaded, pausing the parser for that moment | After the whole document is parsed | After parsing, like defer (or when ready, with async) |
| Order between scripts | Document order | Whichever finishes downloading first | Document order | Document order (not with async) |
| Waits for earlier CSS | Yes | No | Yes, before it runs | Yes, like defer |
Delays DOMContentLoaded | Yes | No | Yes, runs just before it | Yes (not with async) |
| Good for | Almost nothing today | Independent scripts: analytics, ads, chat widgets | Your app code that needs the DOM and runs in order | Modern app code with ES modules |
Details that trip people up
asyncanddeferonly work on external scripts. On an inline classic script (nosrc) both are ignored, and it runs at once.- Module scripts are deferred by default, inline or external. Adding
deferdoes nothing; addingasyncmakes them run as soon as their whole import graph is ready. - Modules have a discovery waterfall. The browser only learns that
main.jsimportsrouter.jsafter it downloads and parsesmain.js. A deep import chain costs a round trip per level. Bundling, orlink rel="modulepreload"for the important modules, flattens it. - Scripts inserted by JavaScript are async by default.
document.createElement("script")downloads in parallel and runs whenever ready. Setscript.async = falseto keep insertion order. deferin theheadbeats a script at the end of thebody. Both avoid blocking the parser, but in theheadthe download starts as soon as the first bytes of HTML arrive.- Browsers fetch
asyncanddeferscripts at a lower priority than a parser-blocking script in thehead, so they compete less with your CSS and hero image.
<head>
<!-- tiny, must run before paint: inline and first -->
<script>
document.documentElement.dataset.theme =
localStorage.getItem("theme") ?? "light";
</script>
<link rel="stylesheet" href="/app.4f2c.css">
<!-- app code: needs the DOM, order matters -->
<script type="module" src="/main.9a1b.js"></script>
<link rel="modulepreload" href="/router.77c0.js">
<!-- independent third party: run whenever -->
<script async src="https://analytics.example.com/a.js"></script>
</head>
Check your understanding · Exercise 5
vendor.js (200 KB) and app.js (5 KB) both have async, and app.js calls a function defined in vendor.js. Which statements are true? Select all that apply.
From the render tree to pixels: style, layout, paint, composite
Once the DOM has content and the CSSOM is complete, the browser combines them. It walks the DOM, finds the rules that match each element, and computes its final style. The result is the render tree: one entry for each thing that will produce a box on screen. Chromium calls its version the layout tree, but the idea is the same.
display: none removes the whole subtree, while visibility: hidden keeps the box: invisible, but still laid out.What is not in the render tree
- Non-visual elements:
head,meta,link,script,title. They are in the DOM but draw nothing. - Elements with
display: none, and all their descendants. No box is made, so they cost nothing in layout or paint. - But
visibility: hiddenandopacity: 0elements are in it. They are invisible yet still take up space, so layout must place them. - Pseudo-elements are added.
::beforeand::afterwith acontentvalue are not in the DOM, but they do produce boxes.
This matters for performance work. Hiding a heavy panel with display: none takes it out of layout and paint entirely. Hiding it with visibility: hidden or opacity: 0 keeps paying for it.
The four stages after the trees
- Style (Chrome shows it as Recalculate Style): compute the final value of every property for every element, resolving the cascade, inheritance, and units like
emand%. - Layout (also called reflow): compute the exact position and size of every box for this viewport. Text wrapping happens here, so a font change can move everything below it.
- Paint: record a list of drawing commands for each layer, in the right stacking order. Turning those commands into actual pixels (rasterizing) happens on other threads, often on the GPU.
- Composite: the compositor thread stacks the rasterized layers, applies transforms, opacity, and scrolling, and hands the final frame to the GPU.
For the first paint, every stage runs once over the whole page. On a typical page that takes tens of milliseconds. On a page with a huge DOM, say 6,000 elements on a mid-range phone, style and layout alone can take a few hundred. Lighthouse's DOM size check has long flagged pages with more than about 1,400 elements for this reason.
Which changes trigger which stages
After the first paint, the pipeline restarts whenever something changes, but only from the first stage the change affects. This is the most useful fact for animation and interaction performance.
transform and opacity, not top or width. The first pair can skip the main thread entirely; the second re-runs layout for the element and often for everything around it.- Geometry changes re-run style, layout, paint, and composite:
width,height,margin,padding,top,left,font-size, adding or removing elements, changing text. - Paint-only changes skip layout:
color,background-color,box-shadow,outline,background-image. - Compositor-only changes skip layout and paint when the element has its own layer:
transformandopacity. This is why smooth animations usetransform: translateX()instead ofleft.
For long pages there is a direct way to shorten this part of the path: content-visibility: auto on sections below the fold tells the browser to skip their style, layout, and paint until they come near the viewport. Pair it with contain-intrinsic-size so the scrollbar does not jump. It is supported in all major browsers since 2024.
Check your understanding · Exercise 6
Which of these produce a box in the render tree? Select all that apply.
Counting the critical path in round trips and bytes
On a phone, the first paint is usually limited by round trips, not by bandwidth. A round trip (RTT, round-trip time) is the time for a packet to reach the server and an answer to come back. On a good 4G connection it is around 50 to 100 ms; on a busy one it can be several hundred. Lighthouse's mobile test simulates a slow 4G link with a 150 ms RTT.
Where the 14 KB number comes from
A new TCP connection does not send at full speed: it starts with a small congestion window, the amount of data the server may send before waiting for an acknowledgement. Most servers use an initial window of 10 packets (proposed in RFC 6928, and the Linux default since 2011), and a packet carries about 1,460 bytes of data, so the first round trip delivers about 14 KB, headers included. This phase is called slow start: each round trip the window roughly doubles, to 28 KB, then 56 KB, then 112 KB, until it hits packet loss or the link's capacity. QUIC, used by HTTP/3, starts with a similar window.
So bytes cost round trips on a new connection, and in steps. A 14 KB file and a 15 KB file are not almost the same: the second needs a second round trip. The window sizes add up to 14, 42, 98, 210, and 434 KB after one to five round trips.
A worked example
Take a product page on a 4G phone with a 100 ms RTT and enough bandwidth (about 20 Mbps) that slow start, not bandwidth, is the limit. The connection is new. The HTML is 10 KB, and the head links a 60 KB stylesheet and a 300 KB script with no attributes, all sizes after compression, as sent over the wire.
- 0 to 300 ms, setup. DNS lookup (about one RTT), the TCP handshake (one RTT), and the TLS 1.3 handshake (one RTT). Nothing about the page has been requested yet.
- 400 ms, round trip 1. The HTML request goes out; the first 14 KB window comes back holding all 10 KB of HTML. The parser and the preload scanner find
app.cssandapp.jsand request both at once over the same HTTP/2 connection. - 500 ms, round trip 2. The window is now 28 KB. The browser gave the CSS the highest priority, so the server sends 28 KB of it.
- 600 ms, round trip 3. 56 KB more: the remaining 32 KB of CSS, then 24 KB of the script. The CSSOM can now be built.
- 700 and 800 ms, round trips 4 and 5. 112 KB, then 224 KB: the script is complete at 800 ms.
- 800 to 1,200 ms. The script is parsed, compiled, and run. 300 KB compressed is about 1 MB of JavaScript; on a mid-range phone, 400 ms is a rough estimate, and often an optimistic one.
- About 1,250 ms, first paint. The parser resumes, reaches the body, and style, layout, and paint take about 50 ms more.
Now change one attribute: with defer, the script is no longer critical. The first paint waits only for the HTML and the CSS: 600 ms plus 50 ms of rendering is about 650 ms. Then go one step further: inline the 3 KB of CSS the first screen needs into the HTML (now 13 KB, which still fits the first window with its headers) and load the full stylesheet without blocking. The first paint needs only the HTML: about 450 ms.
| Setup | Critical resources | Critical bytes | Round trips | First paint |
|---|---|---|---|---|
Blocking script in the head | 3: HTML, CSS, JS | 370 KB | 8 | about 1,250 ms |
Script with defer | 2: HTML, CSS | 70 KB | 6 | about 650 ms |
defer plus inlined critical CSS | 1: HTML | 13 KB | 4 | about 450 ms |
Two markup changes, no server work, and the first paint is almost three times sooner. This is a simplified model: it ignores server think time, packet loss, and the browser's exact scheduling. But the shape is right, and it is the kind of reasoning interviewers want to hear. Try your own numbers below.
Check your understanding · Exercise 7
On a new connection with a 100 ms RTT, your HTML is 16 KB compressed and everything else is deferred. Which statements are true? Select all that apply.
Measuring it: the Performance panel, FCP, and LCP
You cannot shorten what you have not measured. There are two kinds of measurement and you need both. Lab data comes from a test you run, on a device and network you choose; it is repeatable and shows the cause. Field data comes from real users (RUM, real user monitoring); it is noisy but shows what people actually experience.
In the lab: the Chrome Performance panel
- Open DevTools, go to Performance, and set network throttling to Slow 4G and CPU throttling to 4x or more. A developer laptop is several times faster than a typical phone.
- Click the reload-and-record button. Chrome records the load from navigation to a few seconds after.
- Read the Network track as a waterfall. Requests that block rendering are marked, and the details show
Render blockingfor them. - Read the Main track below:
Parse HTML,Evaluate Script,Recalculate Style,Layout, andPainttasks. A gap inParse HTMLwith a longEvaluate Scriptis a parser-blocking script. - Find the timing markers: FCP, LCP,
DCL(DOMContentLoaded), andL(load). Everything to the left of FCP is your critical path.
Lighthouse, in the same DevTools, runs a similar test and lists render-blocking requests with the time they cost. WebPageTest gives the same waterfall from real phones in other countries, which is useful when your users are far from your servers.
In the field: PerformanceObserver
Browsers expose paint timings to JavaScript through PerformanceObserver. The buffered: true option delivers entries that happened before your code ran, which matters because your analytics script usually loads after the first paint.
new PerformanceObserver((list) => {
for (const entry of list.getEntriesByName("first-contentful-paint")) {
report("FCP", entry.startTime);
}
}).observe({ type: "paint", buffered: true });
let lcp;
new PerformanceObserver((list) => {
const entries = list.getEntries();
lcp = entries[entries.length - 1]; // a later, larger element replaces earlier ones
}).observe({ type: "largest-contentful-paint", buffered: true });
let reported = false;
addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" && lcp && !reported) {
reported = true; // report once, the first time the page is hidden
report("LCP", lcp.startTime, lcp.element?.tagName, lcp.url);
}
});
LCP keeps updating while the page loads, as larger elements appear, and stops at the first user input, such as a tap, a key press, or a scroll. So you report the last value when the page is hidden. In production, use Google's small web-vitals library, which handles these edge cases (back-forward cache restores, pages opened in a background tab) for you.
Two more APIs help find the cause. The Navigation Timing entry, performance.getEntriesByType("navigation")[0], gives responseStart (TTFB), domInteractive, and domContentLoadedEventEnd. In Chromium, each Resource Timing entry has a renderBlockingStatus of blocking or non-blocking, so you can log which files actually blocked rendering for real users.
Breaking LCP into parts
When LCP is slow, split it into four parts. Each part points at a different fix.
| Part | From and to | If it is large, look at |
|---|---|---|
| TTFB | Navigation start to the first byte of HTML | Server time, redirects, CDN, connection setup |
| Load delay | First byte to the start of the LCP image download | Late discovery: CSS background, client rendering, loading="lazy", low priority |
| Load duration | Start to end of the LCP image download | Image size and format, bandwidth, competing downloads |
| Render delay | Image downloaded to painted | Render-blocking CSS and scripts, long main-thread tasks, fonts |
Check your understanding · Exercise 8
Field data shows LCP at 3.4 s. TTFB is 0.4 s, the hero image starts downloading at 2.6 s, and finishes at 3.0 s. Which fixes target the biggest part? Select all that apply.
Making it short: CSS, hints, scripts, and fonts
Every fix in this chapter does one of three things: removes a resource from the critical path, makes a critical resource smaller, or makes a late resource start sooner. Name which one you are doing and the trade-off becomes clear.
Critical CSS
Critical CSS is the small set of rules needed to render the first screen: layout of the header, the hero, and the fonts and colors they use. You inline it in a style tag in the head, so the first paint needs no stylesheet request at all, and load the full stylesheet in a way that does not block.
<head>
<style>
/* about 3 KB, generated at build time for this page type */
body{margin:0;font-family:Inter,system-ui,sans-serif}
.header{height:56px;display:flex;align-items:center}
.hero{aspect-ratio:16/9;max-width:100%}
</style>
<link rel="preload" href="/app.4f2c.css" as="style">
<link rel="stylesheet" href="/app.4f2c.css" media="print" onload="this.media='all'">
</head>
The media="print" trick works because a non-matching stylesheet does not block; when it loads, the handler switches it to all. Tools like Beasties (the maintained fork of Critters, built into Angular's build) and Penthouse extract the critical rules at build time. The cost: inlined CSS is not cached separately, so it is sent again with every page, and if the critical set misses a rule the page shifts when the full file lands.
Decision How should the page deliver its CSS on the first load?
What we pick: Start with per-route CSS from your framework (Next.js, Remix, Astro, and Vite do this by default) and keep the total small. Add inlined critical CSS for the few pages where first visits on mobile matter most, such as landing and product pages. A single global stylesheet is fine for small apps and internal tools.
Resource hints: preload, fetchpriority, preconnect
link rel="preload"tells the browser about a resource it will need but cannot discover early: a font, a CSS background used as the hero, a module two imports deep. It must have the rightasvalue, and fonts needcrossoriginor the download is wasted. Mistake: preloading everything. Preloads compete with critical files; Chrome warns in the console when a preloaded file is not used within a few seconds.fetchpriority="high"on the LCPimgmoves it ahead of other images, which Chrome otherwise starts at a low or medium priority until layout shows they are in view.fetchpriority="low"works for carousel slides that are not visible. It has worked in all major browsers since late 2024.link rel="preconnect"opens the DNS, TCP, and TLS connection to another origin early, saving up to three round trips when the first request to it comes. Use it for the one or two origins on the critical path, such as your image CDN or font host. Mistake: preconnecting to ten origins, which wastes the phone's radio and CPU on sockets that time out unused.- 103 Early Hints lets a server send
Linkheaders for preloads and preconnects before the real response is ready, so the browser starts fetching CSS during the server's think time.
Deferring JavaScript
Give every script defer, async, or type="module" as chapter 5 described. Then make the deferred code smaller: split it by route, load features like a chat widget or a video player on interaction, and audit third-party tags, which often add more script than the app itself. Deferred JavaScript is off the paint path, but it still decides when the page responds to taps.
Fonts and font-display
Web fonts are discovered late: the browser must download the CSS, find the @font-face rule, and see in the render tree that some text uses it. Only then does the font download start. Meanwhile, the text that needs it may be invisible.
font-display | Block period (text invisible) | Then | Use for |
|---|---|---|---|
block | Up to about 3 s | Swap in the font whenever it arrives | Icon fonts, where fallback glyphs are wrong |
swap | Very short, 100 ms at most | Swap in the font whenever it arrives | Brand fonts for headings and body text |
fallback | About 100 ms | Swap only if it arrives within about 3 s | Body text when a late swap would be jarring |
optional | About 100 ms | Use it only if it arrived in time; otherwise this page keeps the fallback | Performance-first sites; best for layout stability |
Preload the one or two font files used above the fold, serve WOFF2, subset to the characters you need, and self-host to avoid a connection to another origin. To stop the swap from shifting the layout, give the fallback font matching metrics with size-adjust and ascent-override in its own @font-face; Next.js next/font does this automatically.
Check your understanding · Exercise 9
The hero is a CSS background-image from img.cdn.example.com, and LCP is slow. Which changes help the image start sooner? Select all that apply.
Using it in a system design interview
Frontend system design interviews almost always reach the first load. "How do you make the product page fast?", "Would you render this on the server?", and "What happens when a user opens this link on a phone?" are all critical rendering path questions in disguise. Bring it up yourself when the product has public, first-visit pages: e-commerce, news, search results, landing pages, anything shared as a link.
What goes in the first response
The strongest single thing you can say is a precise description of the first HTML response, because it decides the critical path. Something like this:
- The head, flushed early: inlined critical CSS (or one small stylesheet),
preconnectto the image CDN,preloadfor the one font the header uses, the LCP image as animgwithfetchpriority="high"or a preload for it. - Server-rendered content for the first screen: real HTML for the header, title, price, and hero, so FCP and LCP need no JavaScript.
- Scripts as
type="module"ordefer: none in the critical path. Third-party tagsasyncor loaded after the page is interactive. - Only the data the first screen needs, serialized for hydration. A 200 KB JSON blob inside the HTML is critical bytes too.
- Everything else streamed or fetched later: reviews, recommendations, and below-the-fold sections arrive as streamed HTML or on scroll.
SSR versus CSR, in critical path terms
Rendering choices are easiest to compare by asking what the first paint of real content waits for.
Decision How should a public product page be rendered for the fastest first load?
What we pick: For public pages where first visits matter, render on the server and stream: the critical path is HTML plus a small amount of CSS, and the hero is discoverable. Serve from the edge when the content is the same for everyone. Keep CSR for pages behind a login where users stay for a long session, such as dashboards and editors, and still keep the shell's CSS small.
What to draw and what to say
Draw a waterfall, not a box diagram. Put time on the x axis and one row each for HTML, CSS, JS, and the hero image, then mark FCP and LCP. Then say how many round trips it takes and which change removes one. Numbers make the answer credible: "on a cold 4G connection with a 100 ms RTT, setup costs about 300 ms before the first byte, so I want the first paint to need nothing beyond the HTML and maybe one stylesheet".
| Level | What a strong answer includes |
|---|---|
| Junior | CSS blocks rendering, scripts get defer, images get sizes and lazy loading below the fold, FCP and LCP named correctly |
| Mid-level | The preload scanner and late discovery, the LCP image as a real img with high priority, critical CSS, fonts with font-display, measuring in the Performance panel |
| Senior | Round-trip math with the 14 KB window, streaming SSR and Early Hints, priorities and their trade-offs, the cost of inlining versus caching, field measurement at the 75th percentile with budgets enforced in CI |
Check your understanding · Exercise 10
An interviewer says: "Our React product page is client-rendered and LCP on mobile is 4.5 s." Which answers address the critical path? Select all that apply.
Summary
- The critical rendering path is what the first paint waits for: the HTML, render-blocking CSS, and parser-blocking scripts. Count it as critical resources, critical bytes, and round trips.
- HTML is parsed incrementally, but the CSSOM is all or nothing, so CSS in the head blocks rendering. Classic scripts block parsing and also wait for the CSS above them.
- The preload scanner can only fetch what is written in the HTML. Fonts, CSS backgrounds,
@import, and anything JavaScript renders are discovered late; make the important ones real tags or preload them. - Your code gets
deferortype="module", independent scripts getasync, and only tiny inline scripts that must run before paint stay blocking. - After the first paint, changes restart the pipeline at the first stage they affect: geometry costs layout and paint, colors cost paint,
transformandopacityonly composite. - On a new connection the first round trip carries about 14 KB, then the window doubles. Setup alone is about three round trips, so every extra critical file or crossed size step is costly on mobile.
- Measure with the Performance panel under throttling and with FCP and LCP from real users at the 75th percentile; break LCP into TTFB, load delay, load duration, and render delay.
- In interviews, describe the first response precisely and draw a waterfall: server-rendered first screen, small or inlined CSS, a discoverable high-priority hero, and no blocking scripts.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Walk me through what the browser does between receiving the first byte of HTML and painting the first pixels.
It decodes and tokenizes the HTML as it streams in and builds the DOM incrementally. A preload scanner reads ahead in the raw HTML and starts downloads for stylesheets, scripts, and images it finds.
Stylesheets in the head are parsed into the CSSOM. Rendering waits until the CSSOM is complete, because a later rule can override an earlier one. A classic script stops the parser until it has downloaded, waited for earlier CSS, and run.
Then it combines DOM and CSSOM into the render tree of visible boxes, computes styles, runs layout to get sizes and positions, paints draw commands, and the compositor assembles layers into a frame on the GPU.
The critical path is everything the first paint had to wait for: the HTML, render-blocking CSS, and parser-blocking scripts, counted in round trips.
Question 2
What is the difference between async and defer, and when do you use each?
Both download in parallel without blocking the parser. async runs the script as soon as it arrives, pausing the parser for that moment, in whatever order files finish. defer waits until parsing is done and runs scripts in document order, just before DOMContentLoaded.
Use defer (or type="module", which is deferred by default) for app code that needs the DOM or depends on other scripts. Use async for independent scripts like analytics, where order does not matter.
Neither works on inline classic scripts, and module scripts add an import discovery waterfall that bundling or modulepreload fixes.
Question 3
Why is CSS render-blocking but images are not? Could a browser render without waiting for CSS?
Without CSS the browser would paint unstyled content and then repaint and shift everything when the styles arrive, a flash of unstyled content. Since any later rule can change any element, it cannot safely paint with half a CSSOM, so it waits.
An image only fills a box whose size CSS and HTML already define, so the page can paint around it and fill it in later. That is why images affect LCP but not FCP.
You can opt stylesheets out with a non-matching media, load them after the first paint, or put a stylesheet in the body so it blocks only the content after it in Chromium.
Question 4
A page's LCP is 4 seconds on mobile. How do you find and fix the cause?
Start with field data at the 75th percentile, split by device, and see which element is the LCP. Then reproduce in the Performance panel with Slow 4G and CPU throttling.
Break LCP into TTFB, load delay, load duration, and render delay. A large load delay means late discovery: the image is a CSS background, rendered by JavaScript, lazy loaded, or low priority. Fix with a real img in server HTML, fetchpriority="high", or a preload.
A large render delay means render-blocking CSS, parser-blocking scripts, fonts, or long tasks: inline critical CSS, defer scripts, and fix font loading. A large load duration means image size and format. A large TTFB means server, cache, or CDN work.
Question 5
For a product page, would you choose SSR or CSR, and what would the first response contain?
SSR with streaming, served from a cache or edge where possible. With CSR the content waits for HTML, then the bundle, then an API call, and the hero image is discovered only after the code runs.
The first response flushes the head at once with critical CSS inlined, a preconnect to the image CDN, a preload for the header font, and module scripts that do not block. The body has server-rendered title, price, and a hero img with fetchpriority="high".
Slower sections such as reviews stream in later. I would keep the first window in mind: on a cold connection, the first 14 KB arrive in one round trip, so the head and the first screen should come early.
Practice it
These design problems lean on the critical rendering path.
References
- Understanding the critical path web.dev's performance course: render-blocking and parser-blocking resources.
- Critical rendering path MDN overview of DOM, CSSOM, render tree, layout, and paint.
- Optimize Largest Contentful Paint The four LCP subparts and the fixes for each.
- The script element The HTML standard, with the diagram of how async, defer, and module scripts load and run.
- Optimize resource loading with the Fetch Priority API How
fetchprioritychanges the order of downloads. - font-display The block and swap periods of each value.
- RFC 6928: Increasing TCP's Initial Window Where the 10-packet, roughly 14 KB, first window comes from.
Read next
- Inside the browserHow a modern browser splits its work across processes and threads, why your JavaScript shares one main thread with layout, paint, and input, and how the event loop, workers, and OffscreenCanvas decide whether a page stays responsive.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- HTTP/1.1, HTTP/2, and HTTP/3The same requests carried three ways (one at a time per TCP connection, many at once on one TCP connection, or many at once on QUIC over UDP), which decides bundling, sprites, sharding, and how a lossy network feels.
- DNS, CDNs, and the edgeHow a name becomes an address, how that address leads to a server a few milliseconds from the user instead of an ocean away, and what code and files a frontend should run and keep there.