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

Optimizing LCP: Reducing TTFB, Resource Load Delay, Load Duration and Render Delay One by One

Mert Özen Nov 15, 2026 13 min 6 views
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
StateFCPLCPTTFBLoad delayLoad durationRender delay
A: start (CSR, background-image)1,10414,8521511,10613,5907
B: CSR, <img priority>1,11614,8521501,12313,5598
C: prerender, background-image31213,84815113113,5539
D: prerender, <img priority>30813,6921512713,51017

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 the data-src of a lazy-load library. The preload scanner sees neither.
  • Adding the LCP image as a CSS background-image or with JavaScript and expecting fetchpriority to 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, document or localStorage unconditionally during prerendering or SSR; the build breaks as in this post.
  • Using MapFallbackToFile together with UseDefaultFiles without UseRouting(); 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.