Mert Özen Her Satırda Daha İleri
Frontend Web Performansı — #03

LCP'yi Optimize Etmek: TTFB, Resource Load Delay, Load Duration ve Render Delay'i Tek Tek Düşürmek

Mert Özen 15 Kas 2026 13 dk 5 görüntülenme
LCP'yi Optimize Etmek: TTFB, Resource Load Delay, Load Duration ve Render Delay'i Tek Tek Düşürmek

LCP'yi düşürmek için onu TTFB, resource load delay, load duration ve render delay olarak dört parçaya ayırıp her birine ayrı bakmak gerekir; görsel HTML'de keşfedilebilir değilse fetchpriority tek başına işe yaramaz. Bookstore ana sayfasında NgOptimizedImage priority'nin client-side render'da hiçbir şey kazandırmadığını, prerender'ın load delay'i 1,1 sn'den 27 ms'ye indirdiğini ölçüyor; SSR tuzaklarını (document, transfer cache, UseDefaultFiles) gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (Angular 22.2'nin desteklediği TypeScript 6.0 ile, @angular/ssr 22.2.7), web-vitals 6.2.3, .NET 10 (ASP.NET Core 10) ve Playwright 1.65 ile denenmiştir. Bütün sayılar tek bir makinede, yerel build üzerinde, headless Chromium 158 ile alınmış lab ölçümleridir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) ana sayfası, önceki yazının sonunda telefonda (mobile profil, beş koşunun ortancası) şu durumdaydı:

$ 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 (Largest Contentful Paint; viewport'taki en büyük içeriğin çizildiği an) 14,85 sn ve LCP element'i öne çıkan kitabın, Dune'un kapağı. Bu yazıda üç değişiklik deneyeceğiz: kapağı NgOptimizedImage priority ile önceliklendirilmiş gerçek bir <img> yapmak, ana sayfayı build sırasında prerender etmek ve ikisini birlikte uygulamak. Okumaya devam etmeden önce tahmin et: hangisi LCP'yi en çok düşürür ve LCP 2,5 sn'nin altına iner mi?

LCP'nin Dört Alt Parçası

Bir görselin LCP'si arka arkaya gelen dört süreye bölünüyor; aralarında boşluk ya da çakışma yok, toplamları LCP'ye eşit:

  • TTFB (Time to First Byte; ilk byte süresi): navigasyonun başlamasından HTML'in ilk byte'ının gelmesine kadar. Sunucu süresi, ağ gecikmesi ve yönlendirmeler.
  • Resource load delay: TTFB'den LCP görselinin isteğinin başlamasına kadar. Görsel geç keşfediliyorsa ya da düşük öncelikle sıraya giriyorsa büyür.
  • Resource load duration: görselin inmesi. Byte sayısı, bant genişliği ve aynı anda inen diğer kaynaklar.
  • Element render delay: görsel indikten sonra ekrana çizilmesine kadar. Render-blocking CSS, main thread'i tutan JavaScript ya da element'i henüz DOM'a (sayfanın tarayıcıdaki ağaç yapısı) koymamış bir framework.

web.dev'in optimize-lcp rehberi iyi optimize edilmiş bir sayfada kabaca TTFB ve load duration'ın %40'ar, iki "delay"in %10'un altında olmasını öneriyor. İsimlerinde "delay" geçen iki parça boşa geçen zaman; sıfıra yaklaşmalılar. Rehberin önemli bir uyarısı daha var: bir parçayı küçültmek LCP'yi düşürmeyebilir, kazanılan süre bir sonraki parçaya kayabilir. O yüzden dördüne birlikte bakıyoruz. İlk yazıdaki RUM (gerçek kullanıcı ölçümü) kodu bu dört değeri web-vitals'ın attribution build'inden zaten gönderiyor; measure.mjs da ortancalarını yazdırıyor.

Bookstore'un başlangıç durumunda tablo net: TTFB 151 ms (lab profilindeki gecikme kadar), load delay 1,1 sn, load duration 13,6 sn, render delay 7 ms. Sorunun büyük kısmı load duration'da; o kısmı byte'lar belirliyor ve bir sonraki yazının konusu. Bu yazıda önce keşfi, önceliği ve render'ı düzeltiyoruz.

LCP Element'i HTML'de Görünüyor mu?

Başlangıçta kapak bir div'in background-image'ı ve bu div Angular'ın API'den veriyi alıp template'i render etmesiyle oluşuyor:

    @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>

Tarayıcının görsel isteğini başlatabilmesi için önce main.js'in inip çalışması, sonra /api/books/featured'in dönmesi, sonra stilin hesaplanması gerekiyor; load delay'in 1,1 sn olmasının sebebi bu zincir. Preload scanner CSS'teki background-image'ı göremez; HTML'de yazmayan, JavaScript'in sonradan eklediği bir <img>'i de göremez. LCP görselinin keşfedilebilir olması için iki koşul var: HTML'de <img src> (ya da bir <link rel="preload">) olarak yazması ve HTML'in bu içeriği sunucudan hazır getirmesi.

Adım 1: fetchpriority ve NgOptimizedImage priority

Önce kapağı gerçek bir <img> yapıyoruz. NgOptimizedImage'ın priority attribute'ü üç şey yapıyor: fetchpriority="high" ekliyor, loading="lazy" koymuyor ve decoding="sync" kullanıyor. Sunucu tarafında render edildiğinde bir de <link rel="preload"> ekliyor; bunu birazdan göreceğiz. Diğer kapaklar priority almadığı için NgOptimizedImage onlara varsayılan olarak loading="lazy" veriyor:

@Component({
  selector: 'app-home-page',
  imports: [RouterLink, NgOptimizedImage],
  template: `
    @if (featured.value(); as book) {
      <section class="hero">
        <!-- Gerçek bir <img>: keşfedilebilir, önceliklendirilebilir; priority = fetchpriority="high" + lazy loading yok -->
        <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 değişmedi (14.852 ms). Load delay hâlâ 1,1 sn, çünkü uygulama hâlâ client-side render ediliyor ve <img> ancak JavaScript ve API'den sonra DOM'a giriyor; fetchpriority keşfi hızlandırmıyor, yalnız sırayı değiştiriyor. Load duration da değişmedi: diğer sekiz kapak loading="lazy" olsa da telefon ekranında viewport'a yakın oldukları için Chromium onları da hemen istiyor ve hepsi aynı anda iniyor. Bu lab'da API HTTP/1.1 konuşuyor ve tarayıcı origin başına altı bağlantı açıyor; başlamış indirmeler bant genişliğini paylaşırken yüksek öncelik tek başına bir şey kazandırmadı. Değişiklik yine de doğru: sonraki adımlar ancak gerçek bir <img> ile işe yarıyor.

Adım 2: Ana Sayfayı Prerender Etmek

Load delay'i asıl düşürecek olan, kapağı içeren HTML'in sunucudan hazır gelmesi. Bookstore'un ana sayfası her kullanıcıya aynı içeriği gösteriyor (haftanın kitabı ve yeni çıkanlar); bu yüzden her istekte render etmek (SSR) yerine build sırasında bir kez render edip statik HTML olarak sunmak, yani prerender ya da SSG (Static Site Generation; statik site üretimi), yetiyor. Angular'da paketler ve iki yeni dosya:

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)),
    // Prerender adımı build sırasında çalışır ve ayakta bir Bookstore API'si ister
    { provide: API_ORIGIN, useValue: process.env['BOOKSTORE_API'] ?? 'http://127.0.0.1:5300' },
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

outputMode: "static" ile build bir Node sunucusu üretmiyor; ana sayfa için prerender edilmiş index.html'i, diğer route'lar için de client-side render edilecek index.csr.html'i yazıyor. app.config.ts'e provideClientHydration() ekleniyor; tarayıcı prerender edilmiş DOM'u atıp yeniden çizmek yerine onu hydrate ediyor. Hydration'ın ve incremental hydration'ın ayrıntıları bu serinin SSR yazısında.

İlk build denemesi şöyle bitti:

$ 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)

Prerender, uygulamayı Node'da çalıştırıyor ve provideAppInitializer orada da çalışıyor. İlk yazıda yazdığımız RUM kodu web-vitals'ı başlatıyor; o da document'a dokunuyor. Çözüm, ölçümü yalnız tarayıcıda başlatmak:

export function provideRum(options: RumOptions): EnvironmentProviders {
  return makeEnvironmentProviders([
    provideAppInitializer(() => {
      // Yalnız tarayıcıda: prerender sırasında ölçülecek bir sayfa yok
      if (!isPlatformBrowser(inject(PLATFORM_ID))) return;
      // Sampling sayfa görüntüleme başına: bu ziyaretin ya bütün metrikleri gider ya hiçbiri
      if (Math.random() < options.sampleRate) {
        startRum(options, inject(Router), inject(DOCUMENT));
      }
    }),
  ]);
}

İkinci sorun veri: prerender build sırasında çalışıyor ve httpResource'un /api/books/featured isteğinin gideceği bir sayfa origin'i yok. API adresini bir token'la veren küçük bir interceptor yazdık:

import { DOCUMENT, InjectionToken, inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';

// Tarayıcı: sayfanın kendi origin'i. Prerender (app.config.server.ts): build sırasında API'nin adresi.
// İki taraf da aynı mutlak URL'yi gönderir, böylece HTTP transfer cache sunucunun yanıtını bulur.
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);

Tarayıcıda da URL'yi mutlak yapmamızın sebebi HTTP transfer cache. Prerender sırasında alınan yanıtlar HTML'in içine (ng-state script'i) gömülüyor ve tarayıcı aynı isteği yaptığında ağa gitmeden oradan okunuyor. Anahtar istek URL'sinden üretiliyor; ilk denemede tarayıcı göreli /api/..., sunucu mutlak http://127.0.0.1:5300/api/... kullandığı için anahtarlar tutmadı ve tarayıcı hydration'dan sonra iki API isteğini yeniden gönderdi (waterfall'da 4,3 sn'de başlayıp ancak 13,9 sn'de biten iki gereksiz istek). Üretimde sunucunun API'ye iç ağdan farklı bir adresle gittiği durumda HTTP_TRANSFER_CACHE_ORIGIN_MAP token'ı iç adresi dış adrese eşliyor; yalnız sunucu config'inde verilmesi gerekiyor.

Son parça ASP.NET Core. / için prerender edilmiş index.html, diğer her route için index.csr.html sunulmalı:

app.UseResponseCompression();
// "/" -> index.html, build sırasında prerender edilen ana sayfa
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.Combine(builder.Environment.ContentRootPath, "Covers")),
    RequestPath = "/covers",
});
// Route eşleştirme statik dosyalardan sonra: yoksa aşağıdaki fallback endpoint
// "/"'ı zaten eşler ve UseDefaultFiles prerender edilen index.html'i hiç sunmaz
app.UseRouting();

app.MapCatalogEndpoints();
app.MapRumEndpoints();
// Diğer bütün route'lar istemcide render edilir: prerender edilen ana sayfayı değil, CSR kabuğunu sun
app.MapFallbackToFile("index.csr.html");

UseRouting()'i biz eklemezsek WebApplication routing'i pipeline'ın en başına koyuyor; fallback endpoint /'ı da eşliyor, UseDefaultFiles endpoint seçilmiş bir isteğe dokunmuyor ve ana sayfa olarak sessizce CSR (client-side rendering; sayfanın tarayıcıda render edilmesi) kabuğu dönüyor. Bunu curl ile fark ettik: /index.html prerender edilmiş sayfayı, / ise boş <app-root>'u döndürüyordu. Build çıktısındaki index.html'den birkaç satır:

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

Kapak artık HTML'de bir <img> ve NgOptimizedImage sunucuda render edildiği için <head>'e fetchpriority="high" ile bir preload link'i eklemiş. Beasties de prerender edilmiş içerikte kullanılan iki font için preload link'i üretmiş.

Ölçüm: Hangi Adım Ne Kazandırdı?

Prerender'ın etkisini ayrı görmek için arada bir ölçüm daha yaptık: kapak hâlâ background-image iken prerender (C). Son durum (D) prerender ile <img priority>. Tabloda LCP'nin yanında FCP (First Contentful Paint; ilk içeriğin çizildiği an) da var:

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
DurumFCPLCPTTFBLoad delayLoad durationRender delay
A: başlangıç (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

Prerender FCP'yi 1,1 sn'den 0,3 sn'ye indirdi ve load delay'i neredeyse sıfırladı. C ile D arasındaki fark da ilginç: prerender edilmiş HTML'de inline stil olarak duran background-image bile, HTML parse edilip stil hesaplanır hesaplanmaz istendiği için yalnız 131 ms gecikiyor; <img> ve preload link'i ise görseli HTML'le neredeyse aynı anda başlatıyor (27 ms). Ama LCP yalnız 1,2 sn düştü: tahminin "2,5 sn'nin altı" idiyse, sebep tablonun beşinci sütunu. Load duration hâlâ 13,5 sn ve şimdi LCP'nin %98'i. CLS (Cumulative Layout Shift; beklenmedik kaymaların toplam puanı) de 0,03'ten 0'a indi; içerik artık JavaScript'le sonradan yerleşmiyor.

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

Waterfall nedenini gösteriyor: dune.jpg 190 ms'de, diğer sekiz kapak 336 ms'de başlıyor ve 1,6 Mbps'lik hat dokuz yaklaşık 450 KB'lık dosya arasında paylaşılıyor. Sadece Dune'un kapağı inseydi bu hatta 450 KB yaklaşık 2,3 sn sürerdi (hesap, ölçüm değil). Asıl çözüm byte'ları azaltmak: ekrandaki boyuta uygun genişlik, AVIF (AV1 tabanlı yeni nesil görsel formatı) ya da WebP ve srcset. Bunu bir sonraki yazıda ASP.NET Core'da yapacağız.

Render Delay ve TTFB

Render delay bu ölçümlerde hep 20 ms'nin altında kaldı, çünkü görsel her seferinde sayfanın geri kalanından çok sonra iniyor. Görsel küçülünce durum değişir: client-side render edilen bir sayfada küçük bir görsel main.js'ten önce inebilir ve element DOM'a girene kadar beklemek zorunda kalır. web.dev rehberinin uyarısı tam olarak bu: load duration'dan kazanılan süre render delay'e kayar. Prerender bu yüzden LCP için önemli; element HTML'de olduğu için JavaScript'i beklemiyor. Render delay'i büyüten diğer sebepler render-blocking CSS, uzun task'lar ve @defer gibi içerik geciktiren yapılar; sonuncusu JavaScript maliyeti yazısının konusu.

TTFB burada lab profilinin eklediği 150 ms'lik gecikmeden ibaret, çünkü prerender edilmiş sayfa diskteki statik bir dosya. Gerçek hayatta TTFB'yi üç şey büyütüyor: kullanıcıyla sunucu arasındaki mesafe (CDN, yani içeriği kullanıcıya yakın sunuculardan dağıtan ağ, bunu kısaltır; Domain, DNS ve CDN serisi), her istekte yapılan render ve veri erişimi (SSR'da her istek Angular'ı çalıştırır; sonucu ASP.NET Core Output Cache ya da CDN'de cache'lemek Caching serisinin Output Cache yazısında) ve redirect'ler (her redirect bir tur daha demek). RUM'daki TTFB attribution'ı (waitingDuration, dnsDuration, connectionDuration) hangisi olduğunu ayırmaya yarıyor.

Üretimde Dikkat

LCP element'i herkes için aynı değil. Geniş ekranda başlık metni, telefonda kapak görseli LCP olabilir; giriş yapmış kullanıcıya özel bir banner ya da ilk ziyarette çıkan cookie banner'ı da LCP element'i olabilir. Lab'da iyi, field'da kötü LCP'nin en sık sebebi bu. RUM'da LCP ile birlikte attribution.target'ı (element'in selector'ü) ve url'i (görselin adresi) sakla ve route başına en sık görülen LCP element'lerine bak; düzeltmeyi lab'daki değil, kullanıcıların gerçekten gördüğü element için yap.

Prerender her sayfa için uygun değil. Fiyatı ve stoğu değişen bir kitap detay sayfasını build sırasında üretmek, deploy'lar arasında eski veriyi göstermek demek; o tür sayfalar için SSR ve kısa ömürlü bir cache daha doğru. Ana sayfanın "haftanın kitabı" bile ancak her hafta yeniden build ediliyorsa prerender'a uygun. Build'in API'ye ihtiyacı olduğunu CI/CD pipeline'ında da hesaba kat: prerender adımı API'ye ulaşamazsa, bu yazıdaki ilk denemede olduğu gibi, içeriği boş bir sayfa üretebilir; build'i bu durumda kırmak için prerender edilen HTML'de beklenen içeriği kontrol eden küçük bir adım ekle.

Sık Düşülen Tuzaklar

  • LCP görselini loading="lazy" bırakmak ya da bir lazy-load library'sinin data-src'si ile yüklemek. Preload scanner ikisini de göremez.
  • LCP görselini CSS background-image ya da JavaScript ile eklemek ve fetchpriority'nin bunu telafi edeceğini sanmak.
  • Her görsele priority vermek. NgOptimizedImage beşten fazla priority görselde NG02961 (Angular'ın uyarı kodu) uyarısını veriyor; her preload LCP görseliyle aynı hattı paylaşıyor.
  • Cookie banner'ını ya da bir kampanya modal'ını sayfanın üstüne büyük bir blok olarak koymak; LCP element'i o olur ve geç geldiği için LCP'yi uzatır.
  • Prerender ya da SSR sırasında window, document, localStorage gibi tarayıcı API'lerine koşulsuz dokunmak; build bu yazıdaki gibi kırılır.
  • MapFallbackToFile ile UseDefaultFiles'ı UseRouting() olmadan birlikte kullanmak; ana sayfa sessizce CSR kabuğu döner.
  • Sunucu ve tarayıcıda farklı API URL'leri kullanıp transfer cache'i kaçırmak; hydration'dan sonra aynı veri ikinci kez iner.

Küçük Bir Deneme

★☆☆ measure.mjs'i son durum için --profile desktop ile çalıştır. Masaüstü profilinde LCP'nin alt parçalarının oranı telefondakinden nasıl farklı?

★★☆ app.routes.server.ts'e Dune'un detay sayfası için getPrerenderParams ile bir prerender route'u ekle. Detay sayfasına doğrudan girildiğinde LCP alt parçaları nasıl değişiyor? Fiyat API'de değişirse sayfa ne zaman güncellenir?

★★★ Ana sayfayı RenderMode.Server ile her istekte render eden bir Node sunucusu kur (outputMode: "server"), API'deki /api/books/featured'e 300 ms yapay gecikme ekle ve TTFB ile LCP'yi prerender'la karşılaştır. Sonra API yanıtını kısa süreli cache'leyerek farkı tekrar ölç.

Load delay ve render delay neredeyse sıfır; LCP'nin geri kalanı byte. Bir sonraki yazıda kapakları ASP.NET Core'da ekrandaki boyuta göre küçültüp tarayıcının desteklediği en iyi formatta (AVIF ya da WebP) sunacak, NgOptimizedImage'a bunun için bir loader yazacak ve srcset'i ona ürettireceğiz. Aynı yazıda font'lara da döneceğiz: Türkçe karakterleri içermeyen bir subset'in neden fallback font'a düştüğünü ve font değişiminden kaynaklanan kaymayı size-adjust ile nasıl önleyeceğimizi göreceğiz.