Mert Özen Further With Every Line
Frontend Web Performance — #02

The Critical Rendering Path: How HTML, CSS and JavaScript Reach the Screen and What Blocks Rendering

Mert Özen Nov 15, 2026 12 min 8 views
The Critical Rendering Path: How HTML, CSS and JavaScript Reach the Screen and What Blocks Rendering

The critical rendering path is the longest chain the browser waits on before the first paint; stylesheets in head block rendering and scripts without async/defer block the parser, but not every blocking resource is on the critical path. In the waterfall of Bookstore's home page we measure that fixing the font CSS and analytics does not change FCP because the real chain is main.js, then cut FCP from 2.35 s to 1.10 s with response compression and analytics after load.

Version note: The examples in this post were run with Angular 22.2 (with TypeScript 6.0, the version Angular 22.2 supports), .NET 10 (ASP.NET Core 10), Lighthouse 13.6 and Playwright 1.65. All numbers are lab measurements taken on a single machine against a local build with headless Chromium 158. Browser support data comes from MDN browser-compat-data 8.1.9 (2026-11-12).

Predict First

This is the source index.html of bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series. In the measurements of the first post, the home page's FCP on a phone was 2.35 s:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bookstore</title>
  <base href="/en/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <link rel="stylesheet" href="http://fonts.thirdparty.test:5302/css2?family=Inter:wght@400;700">
  <script src="http://analytics.thirdparty.test:5302/analytics.js"></script>
</head>
<body>
  <app-root></app-root>
</body>
</html>

Before reading on, guess: if we fix the two third-party lines in <head> (the CSS of a font service and an analytics script), how much does FCP drop? Half a second, a second, not at all?

The setup is the same as in the first post: the ASP.NET Core API serves the Angular build; Chromium started by Playwright opens the page five times with the "mobile" profile (4x CPU slowdown, 150 ms latency, 1.6 Mbps) and reads the values from the app's own RUM code. The font service and the analytics vendor are not real: Chromium's --host-resolver-rules option maps the *.thirdparty.test names to a local Node server, which adds 250 ms to every response to imitate the response time of a distant server. We did not send any request to a real third party.

From HTML to Pixels: DOM, CSSOM, Render Tree

02-crp-blocking-en.png

The browser parses the HTML as it arrives and builds the DOM. It downloads every stylesheet it finds and turns it into the CSSOM, the tree of style rules. Together they produce the render tree: the nodes that will really be visible and their computed styles. Then layout computes the position and size of every box, paint produces drawing commands and composite combines the layers and puts them on screen. The critical rendering path is the chain of all the work and resources on this pipeline up to the first paint.

The pipeline has two stopping points. The first is CSS: because the browser does not want to show unstyled content for a moment and then redraw it, it paints nothing until the stylesheets in <head> have arrived. That is render-blocking. The second is JavaScript: a <script> without async or defer may change the DOM, so the parser stops until it has been downloaded and run. That is parser-blocking. Since a script can also read the CSSOM, it does not run before the stylesheet in front of it has arrived; a sync script inherits the delay of the CSS before it.

There is a helper that does not sit idle while the parser waits: the preload scanner. It scans the raw HTML ahead, finds src and href values and starts downloading them before the parser gets there. That is why an <img> or <script src> written in the HTML is discovered early, while a resource added to the DOM later by JavaScript or sitting in CSS as a background-image is only discovered when the code that adds it runs. That is exactly the LCP problem of the next post.

defer, async and type="module"

TagStops the parser?When does it run?Order kept?
<script src>Yes, until fetched and runImmediatelyYes
<script async>No (only while running)As soon as it arrivesNo
<script defer>NoAfter parsing, before DOMContentLoadedYes
<script type="module">No (deferred by default)After parsingYes

The details are in the "Your First JavaScript Script: The script Tag, defer, async and DevTools" post of the JavaScript Basics series; what matters here is the difference between a script not stopping the parser and a script not being on the critical path. We will see that difference in measurements shortly.

The index.html Produced by the Angular Build

The index.html produced by the production build differs from the source:

<!doctype html>
<html lang="en" data-beasties-container>
<head>
  <meta charset="utf-8">
  <title>Bookstore</title>
  <base href="/en/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <link rel="stylesheet" href="http://fonts.thirdparty.test:5302/css2?family=Inter:wght@400;700">
  <script src="http://analytics.thirdparty.test:5302/analytics.js"></script>
<style>*{box-sizing:border-box}body{margin:0;font-family:Inter,system-ui,sans-serif;color:#0f172a;background:#f8fafc;line-height:1.5}</style><link rel="stylesheet" href="styles-SR7LJ3IE.css" media="print" data-beasties-media="all"><noscript><link rel="stylesheet" href="styles-SR7LJ3IE.css"></noscript></head>
<body>
  <app-root></app-root>
<script src="main-EWTR2XZC.js" type="module"></script><script>document.querySelectorAll('link[data-beasties-media]').forEach(function(l){l.media=l.getAttribute('data-beasties-media');l.removeAttribute('data-beasties-media')})</script></body>
</html>

Three things stand out. First, the part of styles.css needed for the first view (here the body rules) has been inlined as a <style> by beasties; the real file is requested with media="print". A media query that does not match the screen does not block rendering, and the small inline script at the end of the body switches media to all. Angular's inlineCritical optimization is on by default in production builds. Second, main.js comes with type="module", so it does not block the parser. Third, the two lines we added by hand stay at the top of <head>: Angular does not touch them. The budgets in angular.json only count the files Angular produces; the bytes these two lines bring in are not in any budget.

The Waterfall of Bookstore's Home Page

The Network panel of DevTools shows the waterfall too (the Network panel post of the Browser DevTools series); to keep it repeatable we use a small script that reads the Resource Timing API. Since Chromium 107 every resource entry has a renderBlockingStatus field that says whether it blocked rendering (in Firefox only in preview, not in Safari):

await page.goto(positionals[0] ?? 'http://127.0.0.1:5300/', { waitUntil: 'networkidle', timeout: 120_000 });
const data = await page.evaluate(async () => {
  // LCP entries are only available through a buffered PerformanceObserver
  const lcp = await new Promise((resolve) =>
    new PerformanceObserver((list) => resolve(list.getEntries().at(-1))).observe({ type: 'largest-contentful-paint', buffered: true }));
  const fcp = performance.getEntriesByName('first-contentful-paint')[0];
  const nav = performance.getEntriesByType('navigation')[0];
  const rows = [nav, ...performance.getEntriesByType('resource')].map((e) => ({
    name: e.name.replace(location.origin, '').replace('http://', '').slice(0, 56),
    start: Math.round(e.startTime),
    end: Math.round(e.responseEnd),
    blocking: e.renderBlockingStatus,
  }));
  return { rows, fcp: Math.round(fcp?.startTime), lcp: Math.round(lcp?.startTime) };
});
resource                                                  start    end  render-blocking
/                                                             0    164  non-blocking
fonts.thirdparty.test:5302/css2?family=Inter:wght@400;70    201    485  blocking
analytics.thirdparty.test:5302/analytics.js                 202   1235  blocking
/main-EWTR2XZC.js                                           203   2308  non-blocking
analytics.thirdparty.test:5302/collect                     1310   1573  non-blocking
/styles-SR7LJ3IE.css                                       1310   1475  non-blocking
/api/books/featured                                        2396   2555  non-blocking
/api/books/new                                             2396   2569  non-blocking
/covers/dune.jpg                                           2571  16265  non-blocking
/covers/emma.jpg                                           2591  16654  non-blocking
/covers/the-hobbit.jpg                                     2591  16227  non-blocking
/covers/pride-and-prejudice.jpg                            2591  17571  non-blocking
/covers/middlemarch.jpg                                    2591  17653  non-blocking
/covers/frankenstein.jpg                                   2591  18396  non-blocking
/covers/jane-eyre.jpg                                      2591  24381  non-blocking
/covers/moby-dick.jpg                                      2591  24575  non-blocking
/covers/war-and-peace.jpg                                  2591  24687  non-blocking
FCP 2420 ms, LCP 16272 ms

The font CSS and the analytics script are marked blocking. Analytics finishes at 1.2 s; the parser waits until then. The font files missing from the list are worth a note too: Chromium downloaded them (when we follow the same load through the DevTools protocol, two woff2 requests show up), but these requests started by CSS from another origin did not make it into the page's Resource Timing list. Keep this blind spot in mind when you watch third-party resources with RUM. Lighthouse finds the same two resources and estimates a saving of 900 ms:

Render-blocking requests: Est savings of 900 ms
  analytics.thirdparty.test:5302/analytics.js totalBytes=76286 wastedMs=1603
  fonts.thirdparty.test:5302/css2?family=Inter:wght@400;700 totalBytes=1806 wastedMs=854

First, Two Obvious Fixes

The analytics script has nothing to do with the page content; there is no reason for it to hold the parser. One word is enough:

--- index.html
+++ index.html
@@ -7,7 +7,7 @@
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <link rel="icon" type="image/x-icon" href="favicon.ico">
   <link rel="stylesheet" href="http://fonts.thirdparty.test:5302/css2?family=Inter:wght@400;700">
-  <script src="http://analytics.thirdparty.test:5302/analytics.js"></script>
+  <script async src="http://analytics.thirdparty.test:5302/analytics.js"></script>
 </head>
 <body>
   <app-root></app-root>

The preload scanner still downloads an async script early, but the parser does not wait for it. For the font CSS there are two ways. The first is to stay with the third party, open the connection in advance and request the font with display=swap:

--- index.html
+++ index.html
@@ -6,7 +6,8 @@
   <base href="/en/">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <link rel="icon" type="image/x-icon" href="favicon.ico">
-  <link rel="stylesheet" href="http://fonts.thirdparty.test:5302/css2?family=Inter:wght@400;700">
+  <link rel="preconnect" href="http://fonts.thirdparty.test:5302" crossorigin>
+  <link rel="stylesheet" href="http://fonts.thirdparty.test:5302/css2?family=Inter:wght@400;700&display=swap">
   <script async src="http://analytics.thirdparty.test:5302/analytics.js"></script>
 </head>
 <body>

The second is to serve the font from our own origin. The font files (Inter, distributed under the Open Font License) are copied to public/fonts/, the @font-face rules go into styles.css and the font line disappears from index.html:

/* Self-hosted Inter (SIL Open Font License, public/fonts/OFL.txt): latin + latin-ext, 400 and 700 */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/inter-latin-ext-400-normal.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/inter-latin-400-normal.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Beasties moves these @font-face rules into the inline CSS as well; the browser requests the font without waiting for a separate CSS request and a separate connection. The measurements of the three changes (median of five runs, ms):

StateFCPLCP
A: start2,34816,196
B: analytics async2,34816,196
C: B + preconnect + display=swap2,35216,208
D: B + font from our own origin2,34416,016
E: D + response compression1,49615,256
F: E + analytics after load1,10414,852

The first three rows are a surprise: we removed the two render-blocking resources and FCP did not move. Lighthouse says something else for D:

Lighthouse 5 runs, mobile: http://127.0.0.1:5300/
run  score    FCP    LCP    TBT    CLS     SI
1       71    606  12227    222  0.000    865
2       67   2111  12001    209  0.034   4367
3       72    610  12001    210  0.037   2438
4       72    610  12001    205  0.034   2457
5       67   2112  12001    206  0.034   4354
med     71    610  12001    209  0.034   2457

Lighthouse's model calculated an FCP of 0.6 s in three of the five runs; the measurement that really applies throttling says 2.34 s. By default Lighthouse loads the page fast and estimates the timing on a slow device with a model, and on this page the estimate misses reality. The insight's "900 ms saving" came from the same model.

The Real Critical Path: main.js

Look at the waterfall again: because <app-root> arrives empty, the first content to paint is created when Angular runs. The longest chain in front of FCP is HTML → main.js (282 KB) → Angular's first render. The CSS and the analytics script were not on that chain; main.js finished at 2.3 s anyway and did not wait for them. Shortening the critical rendering path means shortening the longest chain.

The biggest part of the chain was the bytes of main.js, and the API sent them uncompressed. In ASP.NET Core, response compression is two lines:

builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<BookCatalog>();
builder.Services.AddSingleton<RumStore>();
// Compress text responses (HTML, JS, CSS, JSON); the Brotli/zstd details are in the navigation post
builder.Services.AddResponseCompression();

WebApplication app = builder.Build();

app.UseResponseCompression();
app.UseStaticFiles();

With the default settings Brotli and gzip are enabled, chosen by the client's Accept-Encoding, and Vary: Accept-Encoding is added to the response. main.js went from 282 KB to 114 KB and FCP dropped from 2.34 s to 1.50 s (E in the table). The default Brotli level is the fastest one; serving files precompressed at build time, compression levels and zstd are the topic of the post on speeding up navigations.

The last step is about bandwidth: the async analytics script did not block the parser, but its 76 KB downloaded at the same time as main.js and shared the line. Analytics does not need the first paint; it is loaded after the load event:

--- index.html
+++ index.html
@@ -6,8 +6,14 @@
   <base href="/en/">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <link rel="icon" type="image/x-icon" href="favicon.ico">
-  <link rel="stylesheet" href="http://fonts.thirdparty.test:5302/css2?family=Inter:wght@400;700">
-  <script async src="http://analytics.thirdparty.test:5302/analytics.js"></script>
+  <script>
+    // Analytics after the load event: it no longer competes with main.js for bandwidth
+    addEventListener('load', () => {
+      const script = document.createElement('script');
+      script.src = 'http://analytics.thirdparty.test:5302/analytics.js';
+      document.head.append(script);
+    });
+  </script>
 </head>
 <body>
   <app-root></app-root>

FCP dropped to 1.10 s. The waterfall after compression:

resource                                                  start    end  render-blocking
/                                                             0    157  non-blocking
analytics.thirdparty.test:5302/analytics.js                 168   1260  non-blocking
/main-EWTR2XZC.js                                           168   1410  non-blocking
/styles-EHOBXKHN.css                                        170    329  non-blocking
/fonts/inter-latin-400-normal.woff2                         181    638  non-blocking
analytics.thirdparty.test:5302/collect                     1326   1582  non-blocking
/fonts/inter-latin-700-normal.woff2                        1472   1770  non-blocking
/api/books/featured                                        1477   1634  non-blocking
/api/books/new                                             1478   1642  non-blocking
/covers/dune.jpg                                           1641  15233  non-blocking
/covers/the-hobbit.jpg                                     1655  15210  non-blocking
/covers/emma.jpg                                           1655  15629  non-blocking
/covers/middlemarch.jpg                                    1655  16649  non-blocking
/covers/pride-and-prejudice.jpg                            1655  16664  non-blocking
/covers/frankenstein.jpg                                   1655  17376  non-blocking
/covers/moby-dick.jpg                                      1655  23572  non-blocking
/covers/jane-eyre.jpg                                      1655  23370  non-blocking
/covers/war-and-peace.jpg                                  1655  23684  non-blocking
FCP 1492 ms, LCP 15252 ms

This waterfall is from state E; in F the analytics rows move after load. LCP is still around 15 s: the cover images are requested at 1.6 s and then download for tens of seconds. We deal with them in the next two posts.

Resource Hints: Telling the Browser in Advance

Resource hints tell the browser about something it has not discovered yet. They all have a cost: telling the browser something it already knows is wasted, and requesting something early without need steals bandwidth from the critical resource, as we saw in step F.

  • preconnect: sets up the DNS, TCP and TLS connection to an origin in advance. For one or two third-party origins whose critical resources will certainly be requested. According to web.dev the browser closes a connection that is not used within 10 seconds; resources fetched in anonymous mode, such as fonts, also need crossorigin, otherwise only DNS is resolved.
  • dns-prefetch: DNS resolution only; cheaper, for less certain connections.
  • preload: downloads a given resource (a font, the LCP image, a late-discovered CSS) now. Without the right as the resource cannot get the right priority and scripts may be downloaded twice; a font preload needs crossorigin.
  • modulepreload: downloads and parses an ES module; for lazy chunks.
  • fetchpriority: corrects the browser's priority guess with high or low; it does not speed up discovery, it only changes the order.

There is also 103 Early Hints: while the server prepares the real response, it first sends an interim response with Link: <...>; rel=preload headers and the browser starts requesting critical resources without waiting for the HTML (Chrome 103, Firefox 120, Safari 17 according to browser-compat-data). It helps pages with a long TTFB. The public API of ASP.NET Core 10 has no method for sending 1xx interim responses (its reference assemblies only contain the HttpStatusCode.EarlyHints constant), so 103 is usually produced by the CDN or reverse proxy in front of it. That topic is in the Domain, DNS and CDN series.

In Production

Third-party scripts pile up over time. The marketing team adds a pixel through the tag manager, the support team adds a chat widget, and none of them goes through code review. Mark the FCP and LCP in RUM with tag manager changes as well as deploys; a jump can come without any code change. Also think about the cache lifetime of the font we moved to our own origin: its file name has no hash, so if you give it a long max-age, the old file stays when the font is updated. Angular's own output does not have this problem, those file names carry a hash; the HTTP caching details are in the Caching series.

Weigh response compression against its CPU cost: dynamic responses are compressed again on every request. For static files, producing compressed copies at build time and serving them gives both a better ratio and zero cost at request time. On HTTPS the EnableForHttps option is off by default; before turning it on, assess the BREACH-style attack risk of compressing user-specific data together with secrets in the same response.

Common Pitfalls

  • Thinking "no render-blocking resources" means "short critical path". On this page removing two blocking resources did not change FCP at all; the critical path was main.js.
  • Accepting the estimated savings of Lighthouse insights without measuring. Measure the change before and after with real throttling.
  • Preloading everything. Every preloaded resource shares the line with the critical one; Chrome writes a console warning for unused preloads about 3 seconds after load.
  • Unused preconnects, and font preconnects without crossorigin.
  • Thinking async is free. A script that does not block the parser still costs bandwidth and main thread time.
  • Making a critical script "async" by adding it to the DOM with JavaScript. The preload scanner cannot see it; <script async src> arrives earlier. For non-critical scripts such as analytics, being discovered late is exactly what we want.

A Small Experiment

★☆☆ Restart third-party.mjs with DELAY_MS=1000 and run the original index.html and its final version through waterfall.mjs. Which version's FCP is affected by the slower third party?

★★☆ Add <link rel="preload" as="font" type="font/woff2" crossorigin> to index.html for the font served from our own origin and measure with measure.mjs. Then remove crossorigin and check in the DevTools Network panel how many times the font is downloaded.

★★★ Change the analytics script in third-party.mjs to keep the main thread busy for 300 ms instead of 60 ms. FCP will not change in state F; but which metric on which route of the first post's RUM table is affected? Guess, then verify with field-sim.mjs.

FCP went from 2.35 s to 1.10 s, but LCP is still 15 s: the cover image is not discovered until JavaScript runs and the API returns data, and it shares bandwidth with eight other large images. In the next post we split LCP into its four subparts (TTFB, resource load delay, load duration, render delay) and reduce each one separately: we make the cover a real <img>, prioritize it with NgOptimizedImage priority and prerender the home page.