cf.completefrontend
My progress
FUNDAMENTALS · MODULE 2

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 without async or defer (parser-blocking).
  • What does not: images, async and defer scripts, fonts (mostly), stylesheets with a media query 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.
A waterfall on a cold 4G connection with a 100 ms round trip. DNS, TCP, and TLS setup take 0 to 300 ms. The 10 KB HTML arrives at 400 ms. The 60 KB stylesheet arrives at 600 ms and the 300 KB script at 800 ms. The script runs from 800 to 1,200 ms. The screen stays blank until the first paint at about 1,250 ms.DNS+TCP+TLSHTML 10 KBapp.css 60 KBapp.js 300 KBrun app.jsscreenblank white screenfirst paint 1.25 s0 ms400 ms800 ms1200 ms
The server answered quickly and the HTML arrived at 400 ms, yet the screen stays blank for three times as long. The stylesheet and the blocking script are on the critical path.

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.

  1. Bytes to characters. The browser decodes the bytes using the character set, usually UTF-8.
  2. Characters to tokens. The HTML tokenizer turns text like <p class="lead"> into start tags, attributes, text, and end tags.
  3. 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.
  4. 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.
  5. DOM plus CSSOM to the render tree. The browser matches rules to elements and computes the final style of every visible element.
  6. Layout. It works out the size and position of every box, in pixels, for this viewport width.
  7. Paint. It records the drawing commands: fill this rectangle, draw this text, draw this image.
  8. Composite. It splits the page into layers, rasterizes them into pixels, and the compositor stacks them into the final frame on the GPU.
The rendering pipeline as a stack. Bytes from the network feed two parsers: HTML becomes the DOM and CSS becomes the CSSOM. Both feed the render tree, built by matching styles. Then layout, paint, and composite run in a row. Gate 1: a classic script stops the HTML parser. Gate 2: there is no render tree until the CSSOM is complete.Bytes from the networkDOMfrom HTMLCSSOMfrom CSSRender treestyle: match rules, compute valuesLayoutboxesPaintdraw listCompositeGPU frameparsing, style, layout, paint: main threadGate 1: a classicscript stops theHTML parserGate 2: no rendertree until theCSSOM is complete
Every stage needs the one above it. The two gates on the right are where a page usually loses its time: one stops the DOM from growing, the other stops anything from painting.

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.

QuestionRender-blockingParser-blocking
What closes the gateA stylesheet in the head whose media matchesA classic script tag with no async, defer, or type="module"
What stopsPainting. Parsing goes on in the background.Building the DOM. Nothing after the tag exists yet.
Why the browser does itTo 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 gateInline the CSS needed for the first screen, load the rest later, or use mediaAdd 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.

ts
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>");
});
Sending the head before the body is ready (Node, any framework with a writable response).

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 src and srcset, picture sources, 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.
A sequence diagram with three columns: the main parser, the preload scanner, and the network. The first HTML bytes arrive. The parser requests app.css, then meets vendor.js and requests it, and stops. While it waits, the preload scanner reads ahead and requests app.js and hero.avif. When vendor.js arrives, the parser runs it and resumes, with the other files already downloading.ParserPreload scannerNetworkfirst HTML bytesGET app.cssGET vendor.jswaits for vendor.jsGET app.jsGET hero.avifvendor.js arrivesruns it, then resumes parsing
Without the scanner, app.js and the hero image would only be requested after vendor.js had downloaded and run. The scanner only sees URLs written in the HTML, which is why real tags beat files referenced from CSS or JavaScript.

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 the head blocks rendering if its media matches. No media attribute means all, so it always blocks.
  • A style element blocks too, but it costs no request: its CSS is already in the HTML.
  • A stylesheet whose media does not match, such as media="print" or media="(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.
Five ways to include CSS, as seen on a phone. A plain stylesheet link blocks rendering. A link with media width at least 768px does not block on a phone but does on a tablet. A print stylesheet never blocks the screen. An inline style tag blocks but needs no request. A stylesheet link in the body blocks only the content after it in Chromium.<link rel="stylesheet" href="app.css">blocks rendering: no media means all, which always matches<link rel="stylesheet" href="lg.css" media="(width>=768px)">phone: no block, low-priority download; tablet: blocks<link rel="stylesheet" href="print.css" media="print">never blocks the screen, downloads at low priority<style>.hero { aspect-ratio: 16 / 9 }</style>blocks, but costs no request: it is already in the HTML<link rel="stylesheet" href="reviews.css"> (in the body)Chromium: blocks only the content after it
Whether a stylesheet is critical depends on how you include it, not on what is in it. The 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.

BehaviorNo attributeasyncdefertype="module"
DownloadsIn parallel (found by the scanner), but the parser waits for itIn parallel, parser keeps goingIn parallel, parser keeps goingIn parallel, plus every import it pulls in
RunsAt once, where it sitsAs soon as it has downloaded, pausing the parser for that momentAfter the whole document is parsedAfter parsing, like defer (or when ready, with async)
Order between scriptsDocument orderWhichever finishes downloading firstDocument orderDocument order (not with async)
Waits for earlier CSSYesNoYes, before it runsYes, like defer
Delays DOMContentLoadedYesNoYes, runs just before itYes (not with async)
Good forAlmost nothing todayIndependent scripts: analytics, ads, chat widgetsYour app code that needs the DOM and runs in orderModern app code with ES modules
Load the scripts with
Timeline for vendor.js and app.js loaded with no attribute. The parser waits 300 ms. app.js runs at 460 ms and vendor.js at 400 ms, so the order is kept. First paint at about 520 ms; DOMContentLoaded at 790 ms.first paint 520 msDOMContentLoaded 790 msHTML parserapp.cssvendor.jshelpers.jsapp.jsnot used0 ms200 ms400 ms600 ms800 msvendor.js runs before app.js: order keptparsingparser waitsdownloadruns
The parser stops at vendor.js and waits 300 ms for it to download. The body is not even in the DOM yet, so the first paint slips to about 520 ms and DOMContentLoaded to 790 ms.

Details that trip people up

  • async and defer only work on external scripts. On an inline classic script (no src) both are ignored, and it runs at once.
  • Module scripts are deferred by default, inline or external. Adding defer does nothing; adding async makes them run as soon as their whole import graph is ready.
  • Modules have a discovery waterfall. The browser only learns that main.js imports router.js after it downloads and parses main.js. A deep import chain costs a round trip per level. Bundling, or link 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. Set script.async = false to keep insertion order.
  • defer in the head beats a script at the end of the body. Both avoid blocking the parser, but in the head the download starts as soon as the first bytes of HTML arrive.
  • Browsers fetch async and defer scripts at a lower priority than a parser-blocking script in the head, so they compete less with your CSS and hero image.
A decision tree for script attributes. First: must it run before the first paint, and is it tiny? If yes, use an inline script at the top of the head. If no: does it need the DOM or run in order with other scripts? If no, use async. If yes: is it written as ES modules? If yes, use type module; if no, use defer.Must run before paint?and tiny, like a themeyesinline scripttop of the headnoNeeds DOM or order?depends on other scriptsnoasyncyesWritten as ES modules?uses import and exportyesnotype="module"defer
Almost every path ends at a non-blocking choice. A script with no attribute is only right when it is tiny, inline, and must affect the very first paint, such as setting the color theme.
html
<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>
A typical head after applying the decision tree.

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.

Two trees. The DOM: html with head and body; head has a script; body has h1, an aside with display none, and p; p has a span with visibility hidden. The render tree: html, body, h1, and p with its span. The head, the script, and the hidden aside are dropped; the visibility hidden span stays.DOMhtmlheadbodyscripth1asidepspanRender treehtmlbodyh1pspanaside: display nonedropped: head, script, asidehidden span still takes space
The render tree holds only what makes a box. 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: hidden and opacity: 0 elements are in it. They are invisible yet still take up space, so layout must place them.
  • Pseudo-elements are added. ::before and ::after with a content value 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

  1. Style (Chrome shows it as Recalculate Style): compute the final value of every property for every element, resolving the cascade, inheritance, and units like em and %.
  2. 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.
  3. 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.
  4. 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.

A matrix of properties against the four stages. Changing width, top, or font-size runs style, layout, paint, and composite. Changing color or box-shadow runs style, paint, and composite, and skips layout. Changing transform or opacity on a composited layer runs only composite.StyleLayoutPaintCompositewidthrunsrunsrunsrunstoprunsrunsrunsrunsfont-sizerunsrunsrunsrunscolorrunsskippedrunsrunsbox-shadowrunsskippedrunsrunstransformskippedskippedskippedrunsopacityskippedskippedskippedrunstransform and opacity assume the element has its own layer
Animate 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: transform and opacity. This is why smooth animations use transform: translateX() instead of left.

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.

TCP slow start drawn as blocks of 14 KB. Round trip 1 carries one block, round trip 2 two blocks, then 4, 8, and 16, for totals of 14, 42, 98, 210, and 434 KB. In the worked example, the HTML fills part of the first block, the 60 KB stylesheet fills round trip 2 and half of round trip 3, and the 300 KB script fills the rest of round trip 3, all of round trip 4, and most of round trip 5.trip 114 KB, total 14 KBtrip 228 KB, total 42 KBtrip 356 KB, total 98 KBtrip 4112 KB, total 210 KBtrip 5224 KB, total 434 KBHTMLapp.cssapp.js
Each round trip carries about twice as much as the one before. A file pays in whole round trips: the stylesheet needs two, and the script finishes only in the fifth.

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.

  1. 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.
  2. 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.css and app.js and request both at once over the same HTTP/2 connection.
  3. 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.
  4. 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.
  5. 700 and 800 ms, round trips 4 and 5. 112 KB, then 224 KB: the script is complete at 800 ms.
  6. 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.
  7. 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.

SetupCritical resourcesCritical bytesRound tripsFirst paint
Blocking script in the head3: HTML, CSS, JS370 KB8about 1,250 ms
Script with defer2: HTML, CSS70 KB6about 650 ms
defer plus inlined critical CSS1: HTML13 KB4about 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.

Round trip
Critical path with a 100 ms round trip on a new connection. HTML arrives at 400 ms, CSS at 600 ms, JavaScript at 800 ms. First paint at about 1250 ms after 8 round trips, with 3 critical resources and 370 KB of critical bytes.setupHTMLapp.cssapp.jsrun app.jsscreenfirst paint 1.25 s0 ms500 ms1 s8 round trips, 370 KB critical
First paint about 1.25 s: 3 critical resources, 370 KB of critical bytes, 8 round trips. Dashed bars are off the critical path. A simplified model: no server time, no packet loss.

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

  1. 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.
  2. Click the reload-and-record button. Chrome records the load from navigation to a few seconds after.
  3. Read the Network track as a waterfall. Requests that block rendering are marked, and the details show Render blocking for them.
  4. Read the Main track below: Parse HTML, Evaluate Script, Recalculate Style, Layout, and Paint tasks. A gap in Parse HTML with a long Evaluate Script is a parser-blocking script.
  5. Find the timing markers: FCP, LCP, DCL (DOMContentLoaded), and L (load). Everything to the left of FCP is your critical path.
A sketch of the Chrome Performance panel. The Network track shows HTML from 300 to 400 ms, app.css and app.js marked render blocking until 600 and 800 ms, and hero.avif from 400 to 900 ms. The Main track shows a short Parse HTML task, a long Evaluate Script task from 800 to 1,200 ms, then style, layout, and paint. FCP and LCP share one marker at 1.25 s.Performance: 100 ms RTT, 4x CPU slowdownFCP and LCPNetworkHTMLapp.cssapp.jshero.avifrender blockingMainParse HTMLEvaluate Scriptstyle, layout, paint
Read it right to left from FCP: what was the main thread doing just before, and which download was it waiting for? Here a long Evaluate Script sits right before FCP, and the script's download before that. The hero image arrived at 0.9 s but could not paint before the script ran, so LCP lands with FCP: all of the wait is render delay.

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.

js
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);
  }
});
Reporting FCP and LCP from real users.

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.

PartFrom and toIf it is large, look at
TTFBNavigation start to the first byte of HTMLServer time, redirects, CDN, connection setup
Load delayFirst byte to the start of the LCP image downloadLate discovery: CSS background, client rendering, loading="lazy", low priority
Load durationStart to end of the LCP image downloadImage size and format, bandwidth, competing downloads
Render delayImage downloaded to paintedRender-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.

Before and after, on the same 4G connection. Before: HTML at 400 ms, app.css at 600 ms, app.js at 800 ms and run until 1,200 ms; first paint at 1.25 s with 3 critical files and 8 round trips. After: HTML with inlined critical CSS at 400 ms; the full CSS and the deferred script load off the critical path; first paint at 0.45 s with 1 critical file and 4 round trips.BeforeAfterHTMLCSSJSrunHTMLCSSJSrunpaint 1.25 spaint 0.45 s3 critical files, 8 round trips1 critical file, 4 round trips
The downloads take about as long as before. What changed is which ones the first paint waits for: three files and eight round trips before, one file and four round trips after.

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.

html
<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>
Inline the first screen's CSS; load the rest without blocking the first paint.

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?

One stylesheet in the head

Bundle all CSS into one hashed file, linked in the head

Cache it for a year with Cache-Control: max-age=31536000, immutable

Pros

  • Simplest build and mental model
  • Cached across pages after the first visit
  • No flash or shift: everything is styled at first paint

Cons

  • The whole file is render-blocking, including rules for other pages
  • First visit pays every byte before anything shows

Inline critical CSS, rest async

Extract above-the-fold rules per page type at build time

Inline them; load the full file with the media trick or preload

Pros

  • First paint needs only the HTML
  • Big win on first visits over slow networks

Cons

  • Inlined bytes are re-sent on every page
  • Missed rules cause a shift when the full file arrives
  • More build complexity; inline handlers need a CSP that allows them

CSS split per route or component

The bundler emits a small CSS file per route or per component

The server links only the files this page uses

Pros

  • Each page blocks only on its own CSS
  • Files cache well and change independently

Cons

  • More requests (cheap on HTTP/2, still round trips if discovered late)
  • Ordering bugs when two files set the same property

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 right as value, and fonts need crossorigin or 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 LCP img moves 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 Link headers 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-displayBlock period (text invisible)ThenUse for
blockUp to about 3 sSwap in the font whenever it arrivesIcon fonts, where fallback glyphs are wrong
swapVery short, 100 ms at mostSwap in the font whenever it arrivesBrand fonts for headings and body text
fallbackAbout 100 msSwap only if it arrives within about 3 sBody text when a late swap would be jarring
optionalAbout 100 msUse it only if it arrived in time; otherwise this page keeps the fallbackPerformance-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), preconnect to the image CDN, preload for the one font the header uses, the LCP image as an img with fetchpriority="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" or defer: none in the critical path. Third-party tags async or 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?

Client-side rendering (CSR)

The server sends an empty shell and a script

The script downloads, runs, fetches data from an API, then renders

Pros

  • Simple hosting: static files on a CDN
  • Fast navigations after the first load

Cons

  • Content waits for HTML, then JS, then the API: at least two extra round trips plus script run time
  • The LCP image is discovered only after the code renders it
  • Slow on budget phones, where running the bundle is the bottleneck

Server-side rendering (SSR)

The server fetches data and renders full HTML per request

The client hydrates it to make it interactive

Pros

  • Content and the LCP image are in the first response, visible to the preload scanner
  • FCP and LCP do not depend on JavaScript

Cons

  • TTFB includes the server's data fetching
  • Hydration still costs main-thread time before taps work
  • Servers to run and scale

SSR with streaming

Flush the head and the page shell at once

Stream slow sections later as their data resolves, behind Suspense boundaries

Pros

  • Low TTFB and early CSS discovery, plus server-rendered content
  • One slow backend call no longer holds the whole page

Cons

  • Needs a framework and infrastructure that support streaming end to end (some proxies buffer)
  • Status code and headers are sent before the slow parts are known

Static generation at the edge (SSG or ISR)

Render pages at build time or on a schedule

Serve them from CDN caches near the user

Pros

  • Lowest TTFB: often one round trip to a nearby edge
  • Same critical path benefits as SSR

Cons

  • Content can be stale; per-user parts must be filled in on the client
  • Not practical for pages unique to each user

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".

LevelWhat a strong answer includes
JuniorCSS blocks rendering, scripts get defer, images get sizes and lazy loading below the fold, FCP and LCP named correctly
Mid-levelThe 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
SeniorRound-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 defer or type="module", independent scripts get async, 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, transform and opacity only 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.

Question 2

What is the difference between async and defer, and when do you use each?

Question 3

Why is CSS render-blocking but images are not? Could a browser render without waiting for CSS?

Question 4

A page's LCP is 4 seconds on mobile. How do you find and fix the cause?

Question 5

For a product page, would you choose SSR or CSR, and what would the first response contain?

Practice it

These design problems lean on the critical rendering path.

References

Read next