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

JavaScript Maliyetini Azaltmak: Lazy Route, @defer Trigger'ları, injectAsync ve Bundle Budget'ları

Mert Özen 15 Kas 2026 12 dk 7 görüntülenme
JavaScript Maliyetini Azaltmak: Lazy Route, @defer Trigger'ları, injectAsync ve Bundle Budget'ları

JavaScript'in maliyetini azaltmak için başlangıç kodunu route'lara bölmek, görünmeyen bölümleri @defer ile ertelemek, tıklamada gereken service'leri injectAsync ile yüklemek ve sonucu bundle budget ile korumak gerekir. Bookstore'da başlangıç kodu 383 KB'tan 359 KB'a indi, Chart.js'li admin raporu ilk yüklemeden çıktı; ama doğrudan girilen lazy sayfalarda LCP'nin 200-300 ms kötüleştiğini de ölçtük.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (Angular 22.2'nin desteklediği TypeScript 6.0 ile, @angular/cdk 22.2.7), Chart.js 4.6.1, 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çı) Angular istemcisi, bir önceki yazının sonunda başlangıçta 383,56 KB JavaScript indiriyordu. INP (Interaction to Next Paint; bir etkileşimden sonraki ilk paint'e kadar geçen süre) yazısında katalog sayfasına CDK'nın (Component Dev Kit; Angular ekibinin component geliştirme araç seti) virtual scroll'unu eklemiştik ve main.js 316 KB'tan 376 KB'a çıkmıştı. Bütün sayfalar aynı route tablosunda, hepsi ilk yüklemede:

import { Routes } from '@angular/router';
import { BookPage } from './catalog/book-page';
import { CatalogPage } from './catalog/catalog-page';
import { HomePage } from './home/home-page';

export const routes: Routes = [
  { path: '', component: HomePage, title: 'Bookstore' },
  { path: 'books', component: CatalogPage, title: 'Catalog - Bookstore' },
  { path: 'books/:isbn', component: BookPage, title: 'Book - Bookstore' },
];

Tahmin et: katalog ve kitap sayfalarını lazy route yaparsak başlangıçtaki JavaScript ne kadar küçülür ve bu üç sayfanın LCP'si (Largest Contentful Paint; sayfadaki en büyük içeriğin çizildiği an) nasıl değişir? Hepsi iyileşir mi?

JavaScript'in Byte Başına Maliyeti

Bir görsel indiğinde decode edilip çiziliyor; aynı boyuttaki bir JavaScript dosyası ise indirildikten sonra parse ediliyor, derleniyor ve çalıştırılıyor, bunların hepsi main thread'de. Ağda gördüğümüz boyut da yanıltıcı: Angular CLI'ın tahminine göre 383,56 KB'lık başlangıç kodu ağda 107,39 KB, ama tarayıcı 383 KB'ı parse ediyor. Telefon profilimizde (4x yavaş CPU, 1,6 Mbps) bu iki maliyet de görünür: CSR (Client-Side Rendering; sayfanın tarayıcıda JavaScript ile oluşturulması) bir sayfada ilk içerik, main.js inip çalışmadan ekrana gelmiyor.

Başlangıç kodunu küçültmenin sırası genelde şu: önce route seviyesinde böl (lazy route), sonra sayfa içinde görünmeyen ya da sonra gereken bölümleri @defer ile ertele, sonra yalnız bir tıklamada gereken service'leri lazy yükle ve son olarak tree-shaking'i bozan import'ları düzelt. Her adımı bir bundle budget ile koru ki bir sonraki pull request geri getirmesin. Başlangıç noktasının build çıktısı:

Initial chunk files | Names         |  Raw size | Estimated transfer size
chunk-DUQPLOIU.js   | -             | 330.57 kB |                92.04 kB
main-YG5QL2FV.js    | main          |  51.37 kB |                14.93 kB
styles-CYMCJ46A.css | styles        |   1.62 kB |               413 bytes
                    | Initial total | 383.56 kB |               107.39 kB
Lazy chunk files    | Names         |  Raw size | Estimated transfer size
chunk-HN53P3WQ.js   | similar-books |   2.37 kB |                 1.06 kB

Kitap sayfasındaki @defer bloğu yüzünden esbuild ortak kodu isimsiz bir chunk'a taşımış; toplam değişmiyor. Source map'e göre bu kodun 23,6 KB'ı @angular/cdk, yalnız katalog sayfası kullanıyor.

Lazy Route: Sayfa Başına Chunk

import { Routes } from '@angular/router';
import { HomePage } from './home/home-page';

export const routes: Routes = [
  // Ana sayfa (LCP sayfası, prerender ediliyor) başlangıç bundle'ında kalır
  { path: '', component: HomePage, title: 'Bookstore' },
  // Lazy route'lar: her sayfa ve yalnız onun import ettikleri (katalog için CDK) kendi chunk'ına gider
  { path: 'books', loadComponent: () => import('./catalog/catalog-page').then((m) => m.CatalogPage), title: 'Catalog - Bookstore' },
  { path: 'books/:isbn', loadComponent: () => import('./catalog/book-page').then((m) => m.BookPage), title: 'Book - Bookstore' },
];

loadComponent bir dynamic import() döndürüyor; esbuild her sayfa için ayrı bir chunk üretiyor ve router o route'a ilk gidildiğinde chunk'ı indiriyor. Ana sayfa eager kalıyor: LCP sayfamız, prerender ediliyor ve kullanıcıların çoğu oraya giriyor.

Initial chunk files | Names         |  Raw size | Estimated transfer size
main-DSCZI5NW.js    | main          | 356.05 kB |                98.87 kB
styles-CYMCJ46A.css | styles        |   1.62 kB |               413 bytes
                    | Initial total | 357.67 kB |                99.28 kB
Lazy chunk files    | Names         |  Raw size | Estimated transfer size
chunk-DGXVJ7Zf.js   | catalog-page  |  28.54 kB |                 7.79 kB
chunk-CQeLNMuD.js   | book-page     |   2.67 kB |                 1.13 kB
chunk-CALZCXOb.js   | similar-books |   2.43 kB |                 1.11 kB

Başlangıç kodu 383,56 KB'tan 357,67 KB'a indi, ağda 107,39 KB'tan 99,28 KB'a. Katalog chunk'ı 28,54 KB ve CDK'yı da yanında götürdü. Ama 05'te CDK'nın getirdiği 60 KB'ın tamamı geri gelmedi. Katalog sayfasını CDK'sız bir sürümle aynı yapıda derlediğimizde main.js 327,09 KB çıktı; yani CDK lazy olsa da başlangıçta 29 KB bırakıyor. Source map'e göre farkın 20,4 KB'ı @angular/core, 8,2 KB'ı rxjs kodu: CDK'nın kullandığı ve esbuild'in bu build'de ortak başlangıç chunk'ında tuttuğu framework kodu. Bir kütüphanenin lazy chunk'a taşınması, maliyetinin tamamının taşınması demek değil; build çıktısında ölçmeden bilemezsin.

Üç sayfanın telefon profilindeki LCP ve FCP (First Contentful Paint; ilk içeriğin çizildiği an) ölçümü (5 koşunun ortancası):

DurumBaşlangıç JS (KB, ham / ağ)/ LCP/books FCP / LCP/books/:isbn FCP / LCP
A: bütün sayfalar eager383,56 / 107,3913841352 / 13521336 / 1904
B: katalog ve kitap sayfası lazy357,67 / 99,2813361248 / 15561248 / 2188
E: B + lazy admin, @defer, injectAsync359,10 / 103,0514481264 / 15721256 / 2220

FCP iki lazy sayfada da 100 ms civarında düştü, 05'te kaybettiğimiz katalog FCP'si geri geldi. Ama LCP iki sayfada da kötüleşti: katalogda 1352'den 1556 ms'ye, kitap sayfasında 1904'ten 2188 ms'ye. Sebep waterfall'da:

resource                                                  start    end  render-blocking
/books                                                        0    156  non-blocking
/main-DSCZI5NW.js                                           164   1169  non-blocking
/styles-CYMCJ46A.css                                        166    321  non-blocking
/fonts/inter-latin-400-normal.woff2                         176    569  non-blocking
/chunk-DGXVJ7Zf.js                                         1227   1492  non-blocking
/fonts/inter-latin-700-normal.woff2                        1228   1567  non-blocking
/api/books                                                 1530   2865  non-blocking
analytics.thirdparty.test:5302/analytics.js                1569   2579  non-blocking
analytics.thirdparty.test:5302/collect                     2644   2901  non-blocking
FCP 1236 ms, LCP 1544 ms

Bu sayfalar tarayıcıda render ediliyor ve sunucunun gönderdiği HTML hangi route'un açılacağını bilmiyor. Tarayıcı önce main.js'i indirip çalıştırıyor (1169. ms), router route'u eşleştirip katalog chunk'ını ancak o zaman istiyor (1227-1492 ms), API isteği de chunk'tan sonra başlıyor (1530. ms). Lazy route, doğrudan o sayfaya giren kullanıcı için bir round trip daha demek. Uygulama içinde başka bir sayfadan gelenler için bu maliyet, router preloading'le gizlenebilir; doğrudan girişler için çözüm HTML'in route'u baştan bilmesi, yani SSR (Server-Side Rendering; HTML'in sunucuda üretilmesi). Bunu bir sonraki yazıda ölçeceğiz. Hangi sayfanın lazy olacağına da RUM'daki (gerçek kullanıcı ölçümü) giriş sayfası dağılımına bakarak karar ver: kullanıcıların yarısı bir kitap sayfasından giriyorsa o sayfanın lazy olması pahalı.

Chart Kütüphanesi ve Bundle Budget

Bookstore'un back office'ine bir performans raporu sayfası ekliyoruz: RUM'daki p75 tablosu ve LCP için bir bar chart. Chart için Chart.js 4.6.1 kullandık. Sayfayı önce eager bir route olarak ekledik ve aynı anda angular.json'daki budget'ı, B'deki 357,67 KB'ın biraz üstüne çektik:

              "budgets": [
                {
                  "type": "initial",
                  "maximumWarning": "380kB",
                  "maximumError": "420kB"
                },
                {
                  "type": "anyComponentStyle",
                  "maximumWarning": "4kB",
                  "maximumError": "8kB"
                }
              ],
$ npx ng build
Initial chunk files | Names           |  Raw size | Estimated transfer size
main-TSVJQRMV.js    | main            | 367.62 kB |               104.48 kB
chunk-DIj1RMPJ.js   | -               | 198.27 kB |                58.20 kB
styles-CYMCJ46A.css | styles          |   1.62 kB |               413 bytes
                    | Initial total   | 567.51 kB |               163.09 kB
Lazy chunk files    | Names           |  Raw size | Estimated transfer size
chunk-jQWBPDkk.js   | catalog-page    |  28.47 kB |                 7.81 kB
chunk-DdRzux1R.js   | book-page       |   2.71 kB |                 1.15 kB
chunk-3LYvsyG5.js   | similar-books   |   2.47 kB |                 1.14 kB
chunk-CAh5tica.js   | report-exporter | 502 bytes |               502 bytes
Application bundle generation failed.
▲ [WARNING] bundle initial exceeded maximum budget. Budget 380.00 kB was not met by 187.51 kB with a total of 567.51 kB.
✘ [ERROR] bundle initial exceeded maximum budget. Budget 420.00 kB was not met by 147.51 kB with a total of 567.51 kB.

Birkaç personelin kullandığı bir sayfa, her müşterinin ilk yüklemesine 198 KB'lık bir Chart.js chunk'ı ekledi ve build kırıldı. Budget'ın işi bu: regresyonu code review'da göze çarpmasını beklemeden build'de yakalamak. maximumWarning CI loglarında kalıyor ve çoğu zaman okunmuyor; maximumError ise pipeline'ı durduruyor. Sınırı bugünkü boyutun biraz üstüne koy ve bilinçli bir büyüme olduğunda pull request'te değiştir. anyComponentStyle gibi diğer budget türleri ayrı ayrı chunk'ları ve component stillerini sınırlıyor. Lab tarafındaki karşılığı Lighthouse CI'da metrik eşikleri (ör. LCP ve TBT (Total Blocking Time; main thread'in bloklandığı toplam süre) için assert); kurulumu GitHub Actions serisinin 06. yazısında, bu yazıda çalıştırmadık.

@defer Trigger'ları ve injectAsync

Admin route'unu lazy yapınca Chart.js başlangıç kodundan çıktı. Sayfanın içinde de chart'ı ve CSV (virgülle ayrılmış değerler dosyası) export'unu ayrı yüklüyoruz:

    <!-- chart.js, chart alanı görünür olunca yüklenir; tarayıcı boştayken önceden indirilir -->
    @defer (on viewport; prefetch on idle) {
      <app-p75-chart [rows]="rows.value() ?? []" />
    } @placeholder {
      <div class="chart-slot"></div>
    }
export class ReportsPage {
  protected readonly rows = httpResource<RumRow[]>(() => '/api/rum/p75?minSamples=1');
  // Exporter'ın kodu tarayıcı boştayken iner, service ilk kullanımda oluşturulur
  private readonly exporter = injectAsync(() => import('./report-exporter').then((m) => m.ReportExporter), { prefetch: onIdle });

  protected async exportCsv(): Promise<void> {
    (await this.exporter()).download(this.rows.value() ?? []);
  }

@defer'in trigger'ları içeriğin ne zaman yükleneceğini belirliyor: on idle (varsayılan, requestIdleCallback), on viewport (placeholder görünür olunca), on interaction (tıklama ya da tuş), on hover, on immediate, on timer(2s) ve when ile bir koşul. prefetch on ... kodun indirilmesini render'dan ayırıyor: chunk tarayıcı boştayken iniyor, içerik viewport'a girince hemen render ediliyor. Placeholder'ın yüksekliği chart'ınkiyle aynı; nedeni bir önceki yazıdaki CLS (beklenmedik layout kaymalarının toplam puanı) hesabı.

injectAsync, Angular 22'de gelen yeni bir API: @angular/core'un typing'lerinde @publicApi 22.0 olarak işaretli. Bir service'i dynamic import ile alıyor ve bir fonksiyon döndürüyor; fonksiyon çağrıldığında chunk iniyor ve service instance'ı oluşturuluyor. Service'in providedIn: 'root' olması gerekiyor. prefetch: onIdle ile kod tıklamadan önce, boş bir anda indiriliyor; tıklama anında ağ beklemesi kalmıyor. Telefon profilinde sayfayı açıp beklediğimizde, scroll ettiğimizde ve "Export CSV"e tıkladığımızda inen JavaScript dosyaları:

   559 ms  /chunk-BGXGIut6.js                            11.6 kB
  1032 ms  /main-XBNY7HR4.js                             50.2 kB
  1203 ms  /chunk-BuwLbaQP.js                            84.4 kB
  1411 ms  /chunk-DMaf4PNH.js                             1.2 kB
  1608 ms  /chunk-BSzDn4ez.js                             0.4 kB
  2125 ms  /chunk-CkzlGH2p.js                            70.7 kB
  2343 ms  /analytics.js                                 76.1 kB
  3168 ms  -- load finished, waiting 3 s (browser idle)
  6169 ms  -- scroll 400 px
  7688 ms  -- scroll 400 px
  9190 ms  -- click text=Export CSV

İlk üç dosya başlangıç kodu, chunk-DMaf4PNH.js rapor sayfası. Exporter (0,4 KB) ve chart (70,7 KB, ağdaki sıkıştırılmış boyut) sayfa açıldıktan hemen sonra iniyor: exporter boşta, chart ise placeholder'ı telefon ekranında zaten görünür olduğu için. Scroll ve tıklama anında hiçbir dosya inmiyor. Tablonun daha uzun olduğu ve chart'ın ekranın altında kaldığı bir durumda on viewport tek başına scroll'u beklerdi; prefetch on idle o zaman işe yarıyor.

Tree-shaking'i Bozan Import'lar

Chart.js'i kullanmanın en kısa yolu import { Chart } from 'chart.js/auto'. Bu entry point, bütün chart türlerini, scale'leri ve plugin'leri kaydediyor; yani bir side effect'li import ve esbuild kullanılmayan chart türlerini atamıyor. Yalnız bar chart'ın ihtiyacı olanları kaydedince:

import { Component, ElementRef, effect, input, viewChild } from '@angular/core';
import { BarController, BarElement, CategoryScale, Chart, Legend, LinearScale, Tooltip } from 'chart.js';
import { RumRow } from './rum-row';

// 'chart.js/auto' bütün chart türlerini, scale'leri ve plugin'leri kaydeder; yalnız bu bar chart'ın kullandıklarını kaydet
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend);

p75-chart chunk'ı 206,88 KB'tan 161,00 KB'a, ağda 61,71 KB'tan 49,18 KB'a indi. Aynı tür sorunlar başka yerlerde de çıkıyor. CommonJS bir bağımlılık tree-shaking'e girmiyor ve Angular CLI build'de bunun için bir uyarı veriyor; uyarıyı allowedCommonJsDependencies ile susturmak yerine ESM (JavaScript'in standart module formatı) sürümü olan bir paket bulmak daha iyi. Her şeyi yeniden export eden barrel dosyaları (index.ts), içlerinden bir module side effect'liyse hepsini bundle'a çekebiliyor. Üçüncü parti script'lerde (video oynatıcı, harita) facade yaklaşımı işe yarıyor: önce statik bir önizleme görseli göster, kullanıcı tıklayınca asıl embed'i yükle. Bundle'ın içini ayrıntılı incelemek için kullanılan araçlar (esbuild metafile, source-map-explorer) ilerideki Build Araçları serisinde.

Üretimde Dikkat

Ana sayfanın LCP'si E'de B'ye göre 110 ms kötü (1336'ya karşı 1448 ms; ayrı iki 5'li koşuda 1452 ve 1448). Başlangıç kodunun toplamı neredeyse aynı (357,67'ye karşı 359,10 KB), ama yapısı değişti: yeni lazy chunk'lar eklenince esbuild başlangıç kodunu üç dosyaya böldü ve Angular bunlardan ikisini <head>'e modulepreload olarak koydu. B'de tek bir main.js vardı. 1,6 Mbps'lik bağlantıda bu dosyalar hero görseliyle baştan aynı bant genişliğini paylaşıyor. Toplam byte'ı ölçmek yetmiyor, LCP'yi de ölç; bir değişiklik başka bir sayfanın chunk yapısını değiştirebiliyor.

Budget'ı yalnız initial için koymak lazy chunk'ları korumuyor; katalog chunk'ı da büyüyebilir. Önemli lazy sayfalar için bundle türünde, adıyla ("name": "catalog-page") ayrı bir budget düşün. RUM'da da route bazında LCP ve FCP'yi izle: lazy route kararının bedelini doğrudan giriş yapılan sayfalar ödüyor ve bu lab'da yalnız o sayfayı ayrıca ölçersen görünüyor.

Sık Düşülen Tuzaklar

En pahalı tuzağı da ölçtük: ana sayfanın hero'sunu @defer (on idle) içine aldık ve placeholder'a aspect-ratio: 2 / 3 verdik.

http://127.0.0.1:5300/ profile=mobile runs=5
run     TTFB    FCP    LCP    CLS
1        151    320   2276 0.0737
2        151    292   2264 0.0737
3        151    296   2264 0.0737
4        150    292   2248 0.0737
5        151    292   2264 0.0737
med      151    292   2264 0.0737
LCP subparts (median): ttfb=151 loadDelay=1541 loadDuration=552 renderDelay=16
LCP element: html>body>app-root>main>app-home-page>section.hero>img.hero-cover

LCP 1448'den 2264 ms'ye çıktı. Ana sayfa prerender edildiği hâlde, @defer sunucuda yalnız placeholder'ı render ediyor; hero görseli ancak JavaScript inip tarayıcı boşa çıktığında DOM'a (sayfanın tarayıcıdaki ağaç yapısı) giriyor (load delay 1541 ms). Placeholder görselle birebir aynı boyutta olmadığı için CLS de 0,0737 oldu.

  • LCP içeriğini ya da ilk ekranda görünen bir bölümü @defer ile ertelemek.
  • Doğrudan giriş yapılan bir CSR sayfasını lazy yapıp LCP'ye bakmamak; bir round trip eklenir.
  • Bir kütüphaneyi lazy chunk'a taşıyınca bütün maliyetinin gittiğini varsaymak. CDK'nın 29 KB'ı başlangıçta kaldı.
  • chart.js/auto gibi her şeyi kaydeden entry point'ler ve side effect'li barrel import'ları.
  • Budget'ı hiç büyütülmeyecek kadar sıkı ya da hiç tetiklenmeyecek kadar gevşek koymak; ikisi de kısa sürede görmezden gelinir.
  • Yalnız toplam byte'a bakmak; dosya sayısı ve yüklenme sırası da LCP'yi değiştiriyor.

Küçük Bir Deneme

★☆☆ angular.json'a katalog chunk'ı için { "type": "bundle", "name": "catalog-page", "maximumError": "35kB" } ekle ve katalog sayfasına başka bir CDK module'ü import ederek build'i kır.

★★☆ Rapor sayfasındaki tabloyu uzat (ör. minSamples=1 yerine tüm metrikleri iki kez listele) ki chart ekranın altında kalsın. chunks.mjs ile prefetch on idle olan ve olmayan iki sürümde chart chunk'ının ne zaman indiğini karşılaştır.

★★★ Katalog sayfasının LCP kaybını geri al: kullanıcı ana sayfadayken "Catalog" linkinin üzerine geldiğinde katalog chunk'ını önceden indiren küçük bir directive yaz (mouseenter'da dynamic import()) ve ana sayfadan kataloğa geçişte, linke tıklamadan sonraki ilk render süresini ölç.

Başlangıç kodu 383 KB'tan 359 KB'a indi ve 160 KB'lık bir chart kütüphanesi hiçbir müşterinin ilk yüklemesine girmedi, ama doğrudan girilen lazy sayfalarda LCP kötüleşti. Bir sonraki yazıda kitap detay sayfasını CSR, SSR ve prerender ile ölçüyoruz: SSR'ın TTFB'ye (Time to First Byte; sunucudan ilk byte'ın gelme süresi) bedeli, incremental hydration'ın hydrate on viewport trigger'ı, event replay ve SSR çıktısını cache'lemek.