Optimizing LCP: Reducing TTFB, Resource Load Delay, Load Duration and Render Delay One by One
To lower LCP, split it into TTFB, resource load delay, load duration and render delay and look at each one; if the image cannot be discovered in the HTML, fetchpriority alone does not help. On Bookstore's home page we measure that NgOptimizedImage priority gains nothing with client-side rendering while prerendering cuts load delay from 1.1 s to 27 ms, and we show the SSR pitfalls (document, transfer cache, UseDefaultFiles).
Version note: The examples in this post were run with Angular 22.2 (with TypeScript 6.0, the version Angular 22.2 supports, and @angular/ssr 22.2.7), web-vitals 6.2.3, .NET 10 (ASP.NET Core 10) and Playwright 1.65. All numbers are lab measurements taken on a single machine against a local build with headless Chromium 158.
Predict First
At the end of the previous post, the home page of Bookstore, the online bookstore used as the shared example across this site's series, looked like this on a phone (mobile profile, median of five runs):
$ node measure.mjs http://127.0.0.1:5300/ --runs 5 --profile mobile
run TTFB FCP LCP CLS
med 151 1104 14852 0.0318
LCP subparts (median): ttfb=151 loadDelay=1106 loadDuration=13590 renderDelay=7
LCP element: html>body>app-root>main>app-home-page>section.hero>div.hero-cover
LCP is 14.85 s and the LCP element is the cover of the featured book, Dune. In this post we try three changes: making the cover a real <img> prioritized with NgOptimizedImage priority, prerendering the home page at build time, and both together. Before reading on, guess: which one lowers LCP the most, and does LCP get below 2.5 s?
The Four Subparts of LCP
The LCP of an image splits into four consecutive durations; there is no gap or overlap between them and they add up to LCP:
- TTFB: from the start of the navigation to the first byte of the HTML. Server time, network latency and redirects.
- Resource load delay: from TTFB to the start of the LCP image request. It grows when the image is discovered late or queued with a low priority.
- Resource load duration: the download of the image. Byte count, bandwidth and other resources downloading at the same time.
- Element render delay: from the end of the download to the paint. Render-blocking CSS, JavaScript holding the main thread, or a framework that has not put the element into the DOM yet.
web.dev's optimize-lcp guide suggests that on a well-optimized page TTFB and load duration take roughly 40% each and the two "delays" stay under 10%. The two subparts with "delay" in their names are wasted time and should be close to zero. The guide has another important warning: shrinking one subpart may not lower LCP, because the time saved can move to the next subpart. That is why we look at all four together. The RUM code from the first post already sends these four values from the attribution build of web-vitals, and measure.mjs prints their medians.
In Bookstore's starting state the picture is clear: TTFB 151 ms (the latency of the lab profile), load delay 1.1 s, load duration 13.6 s, render delay 7 ms. Most of the problem is in load duration; that part is decided by bytes and is the topic of the next post. In this post we fix discovery, priority and rendering first.
Is the LCP Element Visible in the HTML?
At the start the cover is the background-image of a div, and that div only exists after Angular gets the data from the API and renders the template:
@if (featured.value(); as book) {
<section class="hero">
<div class="hero-cover" role="img" [attr.aria-label]="book.title"
[style.background-image]="'url(' + book.coverUrl + ')'"></div>
<div>
<p class="eyebrow">Book of the week</p>
Before the browser can start the image request, main.js has to download and run, /api/books/featured has to return and the style has to be computed; this chain is why the load delay is 1.1 s. The preload scanner cannot see a background-image in CSS, nor an <img> that JavaScript adds later. For the LCP image to be discoverable, two conditions must hold: it must be written in the HTML as an <img src> (or a <link rel="preload">), and the HTML must arrive from the server with that content already in it.
Step 1: fetchpriority and NgOptimizedImage priority
First we make the cover a real <img>. NgOptimizedImage's priority attribute does three things: it adds fetchpriority="high", does not set loading="lazy" and uses decoding="sync". When rendered on the server it also adds a <link rel="preload">; we will see that shortly. The other covers do not get priority, so NgOptimizedImage gives them loading="lazy" by default:
@Component({
selector: 'app-home-page',
imports: [RouterLink, NgOptimizedImage],
template: `
@if (featured.value(); as book) {
<section class="hero">
<!-- A real <img>: discoverable, prioritizable; priority = fetchpriority="high" + no lazy loading -->
<img class="hero-cover" [ngSrc]="book.coverUrl!" width="1200" height="1800" priority [alt]="book.title" />
<div>
<p class="eyebrow">Book of the week</p>
<h1>{{ book.title }}</h1>
<p class="author">{{ book.author }}</p>
<p>{{ book.summary }}</p>
<a [routerLink]="['/books', book.isbn]">View details</a>
</div>
</section>
}
<h2>New arrivals</h2>
<ul class="grid">
@for (book of newArrivals.value() ?? []; track book.isbn) {
<li>
<a [routerLink]="['/books', book.isbn]">
<img [ngSrc]="book.coverUrl!" width="1200" height="1800" [alt]="book.title" />
<span>{{ book.title }}</span>
</a>
</li>
}
</ul>
`,
http://127.0.0.1:5300/ profile=mobile runs=5
run TTFB FCP LCP CLS
1 151 1128 14840 0.0318
2 150 1116 14852 0.0318
3 150 1104 14868 0.0318
4 151 1124 14852 0.0318
5 150 1116 14840 0.0318
med 150 1116 14852 0.0318
LCP subparts (median): ttfb=150 loadDelay=1123 loadDuration=13559 renderDelay=8
LCP element: html>body>app-root>main>app-home-page>section.hero>img.hero-cover
LCP did not change (14,852 ms). Load delay is still 1.1 s, because the app is still rendered on the client and the <img> only enters the DOM after the JavaScript and the API; fetchpriority does not speed up discovery, it only changes the order. Load duration did not change either: although the other eight covers are loading="lazy", they are close to the viewport on a phone screen, so Chromium requests them right away and they all download at the same time. In this lab the API speaks HTTP/1.1 and the browser opens six connections per origin; while downloads that have already started share the bandwidth, a high priority alone gained nothing. The change is still right: the next steps only work with a real <img>.
Step 2: Prerendering the Home Page
What really lowers load delay is HTML that arrives from the server with the cover already in it. Bookstore's home page shows the same content to everyone (the book of the week and new arrivals), so instead of rendering on every request (SSR) it is enough to render it once at build time and serve it as static HTML (prerendering, or static site generation). In Angular, the packages and two new files:
npm install @angular/ssr@22.2.7 @angular/platform-server@22.2.6
{
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"server": "src/main.server.ts",
"outputMode": "static"
}
}
}
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: '**', renderMode: RenderMode.Client },
];
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
import { API_ORIGIN } from './core/api-origin';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(withRoutes(serverRoutes)),
// The prerender step runs at build time and needs a running Bookstore API
{ provide: API_ORIGIN, useValue: process.env['BOOKSTORE_API'] ?? 'http://127.0.0.1:5300' },
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
With outputMode: "static" the build does not produce a Node server; it writes the prerendered index.html for the home page and index.csr.html for the routes rendered on the client. provideClientHydration() is added to app.config.ts; instead of throwing away the prerendered DOM and painting again, the browser hydrates it. Hydration and incremental hydration are covered in detail in this series' SSR post.
The first build attempt ended like this:
$ npx ng build
Prerendered 0 static routes.
Application bundle generation failed. [1.379 seconds]
✘ [ERROR] An error occurred while extracting routes.
ReferenceError: document is not defined
at Cg (file:///home/user/bookstore-web/.angular/prerender-root/…/main.server.mjs:44:20021)
Prerendering runs the app in Node, and provideAppInitializer runs there too. The RUM code we wrote in the first post starts web-vitals, which touches document. The fix is to start measuring only in the browser:
export function provideRum(options: RumOptions): EnvironmentProviders {
return makeEnvironmentProviders([
provideAppInitializer(() => {
// Only in the browser: during prerendering there is no page to measure
if (!isPlatformBrowser(inject(PLATFORM_ID))) return;
// Sample per page view: either every metric of this visit is sent or none
if (Math.random() < options.sampleRate) {
startRum(options, inject(Router), inject(DOCUMENT));
}
}),
]);
}
The second problem is data: prerendering runs at build time, and there is no page origin for the /api/books/featured request of httpResource to go to. We wrote a small interceptor that takes the API address from a token:
import { DOCUMENT, InjectionToken, inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
// Browser: the page's own origin. Prerendering (app.config.server.ts): the API's address at build time.
// Both sides send the same absolute URL, so the HTTP transfer cache finds the server's response.
export const API_ORIGIN = new InjectionToken<string>('API_ORIGIN', {
factory: () => inject(DOCUMENT).location.origin,
});
export const apiOriginInterceptor: HttpInterceptorFn = (req, next) =>
req.url.startsWith('/api/') ? next(req.clone({ url: inject(API_ORIGIN) + req.url })) : next(req);
The reason we make the URL absolute in the browser as well is the HTTP transfer cache. Responses fetched during prerendering are embedded in the HTML (the ng-state script), and when the browser makes the same request it reads them from there without going to the network. The key is derived from the request URL; in the first attempt the browser used a relative /api/... and the server an absolute http://127.0.0.1:5300/api/..., so the keys did not match and the browser sent the two API requests again after hydration (in the waterfall, two needless requests starting at 4.3 s and only finishing at 13.9 s). In production, when the server reaches the API on an internal address different from the public one, the HTTP_TRANSFER_CACHE_ORIGIN_MAP token maps the internal address to the public one; it must only be provided in the server config.
The last piece is ASP.NET Core. / must get the prerendered index.html, every other route index.csr.html:
app.UseResponseCompression();
// "/" -> index.html, the home page prerendered at build time
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(builder.Environment.ContentRootPath, "Covers")),
RequestPath = "/covers",
});
// Route matching after the static files: otherwise the fallback endpoint below already
// matches "/" and UseDefaultFiles never serves the prerendered index.html
app.UseRouting();
app.MapCatalogEndpoints();
app.MapRumEndpoints();
// Every other route renders on the client: serve the CSR shell, not the prerendered home page
app.MapFallbackToFile("index.csr.html");
If we do not add UseRouting() ourselves, WebApplication puts routing at the very start of the pipeline; the fallback endpoint also matches /, UseDefaultFiles does not touch a request that already has an endpoint, and the home page silently returns the CSR shell. We noticed it with curl: /index.html returned the prerendered page, / the empty <app-root>. A few lines from index.html in the build output:
<link rel="preload" as="font" crossorigin="anonymous" href="/fonts/inter-latin-400-normal.woff2">
<link rel="preload" as="font" crossorigin="anonymous" href="/fonts/inter-latin-700-normal.woff2">
<link _ngcontent-ng-c4283705217="" as="image" href="/covers/dune.jpg" rel="preload" fetchpriority="high">
...
<app-root ng-version="22.2.6" _nghost-ng-c3872582091="" ngh="1" ng-server-context="ssg">
...
<img _ngcontent-ng-c4283705217="" width="1200" height="1800" priority="" class="hero-cover" alt="Dune" loading="eager" fetchpriority="high" decoding="sync" ng-img="true" src="/covers/dune.jpg">
...
<script id="ng-state" type="application/json">
The cover is now an <img> in the HTML, and because NgOptimizedImage was rendered on the server it added a preload link with fetchpriority="high" to <head>. Beasties also produced preload links for the two fonts used in the prerendered content.
Measurement: What Did Each Step Gain?
To see the effect of prerendering separately, we took one more measurement in between: prerendering while the cover is still a background-image (C). The final state (D) is prerendering with <img priority>:
http://127.0.0.1:5300/ profile=mobile runs=5
run TTFB FCP LCP CLS
1 151 308 13708 0
2 151 308 13692 0
3 151 308 13708 0
4 151 312 13692 0
5 151 308 13692 0
med 151 308 13692 0
LCP subparts (median): ttfb=151 loadDelay=27 loadDuration=13510 renderDelay=17
LCP element: html>body>app-root>main>app-home-page>section.hero>img.hero-cover
| State | FCP | LCP | TTFB | Load delay | Load duration | Render delay |
|---|---|---|---|---|---|---|
A: start (CSR, background-image) | 1,104 | 14,852 | 151 | 1,106 | 13,590 | 7 |
B: CSR, <img priority> | 1,116 | 14,852 | 150 | 1,123 | 13,559 | 8 |
C: prerender, background-image | 312 | 13,848 | 151 | 131 | 13,553 | 9 |
D: prerender, <img priority> | 308 | 13,692 | 151 | 27 | 13,510 | 17 |
Prerendering cut FCP from 1.1 s to 0.3 s and brought load delay close to zero. The difference between C and D is interesting too: even a background-image sitting as an inline style in the prerendered HTML is only 131 ms late, because it is requested as soon as the HTML is parsed and styles are computed; the <img> and its preload link start the image almost together with the HTML (27 ms). But LCP only dropped by 1.2 s: if you guessed "below 2.5 s", the reason is the fifth column. Load duration is still 13.5 s and is now 98% of LCP. CLS also went from 0.03 to 0, since content no longer moves into place later via JavaScript.
resource start end render-blocking
/ 0 180 non-blocking
/fonts/inter-latin-400-normal.woff2 189 945 non-blocking
/fonts/inter-latin-700-normal.woff2 190 990 non-blocking
/covers/dune.jpg 190 13687 non-blocking
/main-G4DYV3YR.js 190 4192 non-blocking
/styles-EHOBXKHN.css 200 382 non-blocking
/covers/the-hobbit.jpg 336 13866 non-blocking
/covers/emma.jpg 336 14250 non-blocking
/covers/middlemarch.jpg 336 15952 non-blocking
/covers/frankenstein.jpg 336 16904 non-blocking
/covers/pride-and-prejudice.jpg 336 18247 non-blocking
/covers/jane-eyre.jpg 336 22868 non-blocking
/covers/moby-dick.jpg 336 23018 non-blocking
/covers/war-and-peace.jpg 336 23152 non-blocking
analytics.thirdparty.test:5302/analytics.js 23156 23789 non-blocking
analytics.thirdparty.test:5302/collect 23855 24111 non-blocking
FCP 340 ms, LCP 13696 ms
The waterfall shows why: dune.jpg starts at 190 ms, the other eight covers at 336 ms, and the 1.6 Mbps line is shared among nine files of about 450 KB. If only Dune's cover were downloading, 450 KB would take about 2.3 s on this line (a calculation, not a measurement). The real fix is fewer bytes: a width that fits the size on screen, AVIF/WebP and srcset. We do that in ASP.NET Core in the next post.
Render Delay and TTFB
Render delay stayed under 20 ms in all these measurements because the image always finished long after the rest of the page. That changes when the image gets small: on a client-rendered page a small image can arrive before main.js and has to wait until the element enters the DOM. This is exactly the warning in the web.dev guide: time saved from load duration moves to render delay. That is one reason prerendering matters for LCP; the element is in the HTML and does not wait for JavaScript. Other causes of a large render delay are render-blocking CSS, long tasks and constructs that delay content such as @defer; the last one belongs to the post on JavaScript cost.
Here TTFB is nothing but the 150 ms latency the lab profile adds, because the prerendered page is a static file on disk. In real life three things grow TTFB: the distance between user and server (a CDN shortens it; the Domain, DNS and CDN series), the render and data access done on every request (with SSR every request runs Angular; caching the result in ASP.NET Core Output Cache or a CDN is in the Output Cache post of the Caching series) and redirects (every redirect is one more round trip). The TTFB attribution in RUM (waitingDuration, dnsDuration, connectionDuration) helps tell which one it is.
In Production
The LCP element is not the same for everyone. On a wide screen the heading text can be the LCP, on a phone the cover image; a banner for signed-in users or the cookie banner shown on the first visit can also become the LCP element. This is the most common reason for an LCP that is good in the lab and bad in the field. Store attribution.target (the element's selector) and url (the image address) with LCP in RUM and look at the most frequent LCP elements per route; fix the element users actually see, not the one in the lab.
Prerendering is not right for every page. Producing a book page whose price and stock change at build time means showing stale data between deploys; for such pages SSR with a short-lived cache is the better fit. Even the home page's "book of the week" only suits prerendering if it is rebuilt every week. Also account for the build needing the API in the CI/CD pipeline: if the prerender step cannot reach the API it can produce a page with empty content, as in the first attempt in this post; add a small step that checks the prerendered HTML for the expected content and fails the build otherwise.
Common Pitfalls
- Leaving the LCP image as
loading="lazy"or loading it through thedata-srcof a lazy-load library. The preload scanner sees neither. - Adding the LCP image as a CSS
background-imageor with JavaScript and expectingfetchpriorityto make up for it. - Giving every image
priority. NgOptimizedImage warns with NG02961 for more than five priority images; every preload shares the line with the LCP image. - Putting a cookie banner or a campaign modal at the top of the page as a large block; it becomes the LCP element and, arriving late, stretches LCP.
- Touching browser APIs such as
window,documentorlocalStorageunconditionally during prerendering or SSR; the build breaks as in this post. - Using
MapFallbackToFiletogether withUseDefaultFileswithoutUseRouting(); the home page silently returns the CSR shell. - Using different API URLs on the server and in the browser and missing the transfer cache; the same data downloads a second time after hydration.
A Small Experiment
★☆☆ Run measure.mjs for the final state with --profile desktop. How does the share of the LCP subparts differ from the phone?
★★☆ Add a prerender route for Dune's book page to app.routes.server.ts with getPrerenderParams. How do the LCP subparts change when the book page is opened directly? When does the page update if the price changes in the API?
★★★ Set up a Node server that renders the home page on every request with RenderMode.Server (outputMode: "server"), add an artificial 300 ms delay to /api/books/featured in the API and compare TTFB and LCP with prerendering. Then cache the API response briefly and measure the difference again.
Load delay and render delay are almost zero; the rest of LCP is bytes. In the next post we resize the covers in ASP.NET Core to the size on screen and serve them in the best format the browser supports (AVIF or WebP), write a loader for NgOptimizedImage and let it generate the srcset. In the same post we come back to fonts: why a subset without Turkish characters falls back to another font, and how to prevent the shift caused by the font swap with size-adjust.