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

INP'yi Optimize Etmek: Input Delay, Processing ve Presentation Delay, Long Task'ları Bölmek ve Angular'da Akıcı Liste

Mert Özen 15 Kas 2026 12 dk 6 görüntülenme
INP'yi Optimize Etmek: Input Delay, Processing ve Presentation Delay, Long Task'ları Bölmek ve Angular'da Akıcı Liste

INP'yi düşürmek için en kötü etkileşimin hangi fazda (input delay, processing, presentation delay) beklediğini bulup main thread'i bloklayan long task'ı ya bölmek ya da küçültmek gerekir. Bookstore'un 2.000 kitaplık kataloğunda zoneless Angular'ın change detection'ının bir frame callback'inde çalıştığını LoAF ile gösteriyor; computed, doğru track, scheduler.yield ve CDK virtual scroll ile INP'yi 416 ms'den 8 ms'ye indiriyoruz.

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), 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. Tarayıcı destek bilgileri MDN'in (Mozilla'nın web dokümantasyonu) browser-compat-data 8.1.9 sürümünden (2026-11-12).

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) katalog sayfası 2.000 kitabı tek bir tabloda gösteriyor ve üstteki kutuya yazıldıkça listeyi süzüyor. İlk yazıdaki RUM (gerçek kullanıcı ölçümü) tablosunda bu sayfanın telefondaki INP'si (Interaction to Next Paint; bir etkileşimden sonraki ilk paint'e kadar geçen süre) 232 ms ile "needs improvement" çıkmıştı. Sayfanın template'i ve component'i:

  template: `
    <h1>Catalog</h1>
    <input type="search" class="filter" placeholder="Filter by title or author"
           (input)="query.set($any($event.target).value)" />
    <p>{{ filtered().length }} books</p>
    <table class="books">
      <tbody>
        @for (book of filtered(); track $index) {
          <tr>
            <td><a [routerLink]="['/books', book.isbn]">{{ book.title }}</a></td>
            <td>{{ book.author }}</td>
            <td>{{ book.publisher }}</td>
            <td>{{ book.year }}</td>
            <td class="price">{{ formatPrice(book) }}</td>
            <td>{{ book.stock > 0 ? 'In stock' : 'Sold out' }}</td>
          </tr>
        }
      </tbody>
    </table>
  `,
export class CatalogPage {
  protected readonly books = httpResource<Book[]>(() => '/api/books');
  protected readonly query = signal('');

  protected filtered(): Book[] {
    const q = this.query().toLocaleLowerCase('en');
    return (this.books.value() ?? [])
      .filter((b) => `${b.title} ${b.author}`.toLocaleLowerCase('en').includes(q))
      .sort((a, b) => a.title.localeCompare(b.title));
  }

  protected formatPrice(book: Book): string {
    return new Intl.NumberFormat('en-US', { style: 'currency', currency: book.currency }).format(book.price);
  }
}

Okumaya devam etmeden önce tahmin et: bir kullanıcı "war" yazıp sonra üç kez Backspace'e basarak aramayı silerse en kötü etkileşim kaç ms sürer? Ve zamanın çoğu nereye gider: filtrelemeye ve sıralamaya mı, yoksa başka bir yere mi?

Bir Etkileşimin Üç Fazı

INP, sayfadaki her tıklama, dokunma ve tuşa basış için kullanıcının hareketinden ekranda bir sonraki frame'in çizilmesine kadar geçen süreyi ölçüyor ve sayfanın ömrü boyunca en kötülerinden birini raporluyor (web.dev'e göre her 50 etkileşimde en yüksek bir tanesi göz ardı ediliyor, yani çoğu sayfada en kötü etkileşim). Süre üç faza bölünüyor:

  • Input delay: kullanıcının hareketinden event handler'ların çalışmaya başlamasına kadar. Main thread o anda başka bir işle meşgulse büyür.
  • Processing duration: event handler'ların çalışma süresi.
  • Presentation delay: handler'lar bittikten sonra bir sonraki frame'in ekrana gelmesine kadar. Style, layout, paint ve bu arada çalışan requestAnimationFrame callback'leri burada.

Eşikler ilk yazıdaki gibi: 200 ms ve altı "good", 500 ms üstü "poor". Ölçüm için measure.mjs'e bir --clear seçeneği ekledik: script "war" yazdıktan sonra her harfi 150 ms arayla siliyor; son Backspace listeyi yeniden 2.000 satıra çıkarıyor.

Ölçmek ve Sebebi Bulmak

http://127.0.0.1:5300/books profile=mobile runs=5
run     TTFB    FCP    LCP    CLS    INP
1        151   1216   1216      0    416
2        151   1196   1196      0    440
3        151   1192   1192      0    416
4        150   1188   1188      0    408
5        151   1196   1196      0    416
med      151   1196   1196      0    416
LCP subparts (median): ttfb=151 loadDelay=0 loadDuration=0 renderDelay=1046
LCP element: html>body>app-root>main>app-catalog-page>h1
INP subparts (median): inputDelay=1 processing=2 presentation=4

INP 416 ms. Alt parçaların ortancası (1, 2, 4) bir şey söylemiyor, çünkü 400 ms'lik iş her koşuda farklı bir faza düşüyor. Tek bir koşunun RUM raporu daha açık:

{
  "name": "INP",
  "value": 416,
  "rating": "needs-improvement",
  "attribution": {
    "target": "html>body>app-root>main>app-catalog-page>input.filter",
    "type": "keyboard",
    "inputDelay": 413,
    "processing": 0,
    "presentation": 3,
    "script": "FrameRequestCallback"
  }
}

Bu koşuda sürenin tamamı input delay: Backspace'e basıldığında main thread hâlâ bir önceki tuşun işini yapıyordu. script alanı, web-vitals'ın LoAF (Long Animation Frames; uzun frame'leri ve içlerindeki script'leri raporlayan API) kayıtlarından bulduğu en uzun script'in invoker'ı: FrameRequestCallback, yani bir requestAnimationFrame callback'i. LoAF yalnız Chromium'da var; Firefox ve Safari'den gelen INP raporlarında bu alan boş kalır.

Peki o callback kim? Angular 21'den beri yeni projeler zoneless ve Angular 22'de component'ler varsayılan olarak OnPush. Zoneless modda bir signal değişince Angular change detection'ı hemen çalıştırmıyor, setTimeout ile requestAnimationFrame arasında bir yarışla zamanlıyor (@angular/core kaynağındaki scheduleCallbackWithRafRace). query.set() event handler'da 1-2 ms sürüyor; asıl iş, hangisi önce gelirse o callback'te çalışan change detection: filtered()'ın template'te iki kez çağrılması, her çağrıda 2.000 kitabın süzülüp localeCompare ile sıralanması, her satır için yeni bir Intl.NumberFormat kurulması ve 16.030 element'lik DOM'un (sayfanın tarayıcıdaki ağaç yapısı) güncellenmesi. Bu callback rAF'te çalıştığında süre presentation delay'e, timeout'ta çalıştığında bir sonraki etkileşimin input delay'ine yazılıyor. Faz değişse de sorun aynı: tek parça, 400 ms'lik bir long task.

Adım 1: computed, Tek Formatter ve Doğru track

// Satır ve change detection başına bir tane yerine bütün liste için tek formatter
const price = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'TRY' });
        @for (book of filtered(); track book.isbn) {
          <tr>
            <td><a [routerLink]="['/books', book.isbn]">{{ book.title }}</a></td>
            <td>{{ book.author }}</td>
            <td>{{ book.publisher }}</td>
            <td>{{ book.year }}</td>
            <td class="price">{{ book.priceText }}</td>
            <td>{{ book.stock > 0 ? 'In stock' : 'Sold out' }}</td>
          </tr>
        }
  // Veri geldiğinde bir kez hazırlanır: küçük harfli arama metni, biçimlendirilmiş fiyat, sıralama
  private readonly prepared = computed(() =>
    (this.books.value() ?? [])
      .map((b) => ({ ...b, search: `${b.title} ${b.author}`.toLowerCase(), priceText: price.format(b.price) }))
      .sort((a, b) => a.title.localeCompare(b.title)),
  );

  // Her change detection'da değil, yalnız query ya da veri değişince yeniden hesaplanır
  protected readonly filtered = computed(() => {
    const q = this.query().toLowerCase();
    return this.prepared().filter((b) => b.search.includes(q));
  });

Üç değişiklik birlikte: arama metni, biçimlendirilmiş fiyat ve sıralama veri geldiğinde bir kez hesaplanıyor; filtered bir computed olduğu için yalnız query ya da veri değişince yeniden hesaplanıyor ve template'teki iki okuma aynı sonucu kullanıyor; track de satırın konumunu değil kitabın ISBN'ini (kitabın uluslararası standart numarası) izliyor.

http://127.0.0.1:5300/books profile=mobile runs=5
run     TTFB    FCP    LCP    CLS    INP
1        152   1276   1276      0    312
2        150   1212   1212      0    288
3        151   1208   1208      0    304
4        150   1204   1204      0    296
5        151   1208   1208      0    296
med      151   1208   1208      0    296
LCP subparts (median): ttfb=151 loadDelay=0 loadDuration=0 renderDelay=1058
LCP element: html>body>app-root>main>app-catalog-page>h1
INP subparts (median): inputDelay=293 processing=0 presentation=2

INP 416 ms'den 296 ms'ye indi. Kalan 300 ms'nin büyük kısmı artık hesaplama değil, DOM: son Backspace'te yaklaşık 2.000 satır yeniden oluşturuluyor ve tablo baştan layout ediliyor. track $index ile track book.isbn arasındaki fark burada hız değil, doğruluk: $index DOM satırlarını konuma bağlıyor ve süzme sonrası birinci satırda başka bir kitap gösteriyor; satırda bir checkbox, açık bir menü ya da animasyon varsa o yanlış kitaba geçiyor. Kitabın kendisini (track book) izlemek de tuzak: API'den yeniden gelen her liste yeni object'ler demek ve bütün satırlar silinip baştan kuruluyor. Kararlı bir anahtar (ISBN) ikisinden de kaçınıyor.

Adım 2: Long Task'ı scheduler.yield ile Bölmek

Kullanıcının bir tuşa bastığında görmesi gereken ilk şey harfin kutuda belirmesi; listenin hemen ardından güncellenmesi yeterli. Bu yüzden event handler'ı ikiye bölüyoruz: önce main thread'e dönüyoruz, liste güncellemesi yeni bir task'ta çalışıyor ve tarayıcı araya bir frame sığdırabiliyor:

// scheduler.yield(): Chrome 129+, Firefox 142+; Safari'de Scheduler API henüz yok -> setTimeout fallback'i.
// scheduler.yield()'ın devamı sıradaki diğer task'lardan önce, setTimeout'unki en sonda çalışır.
export function yieldToMain(): Promise<void> {
  const scheduler = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler;
  if (scheduler?.yield) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}
  // Input task'ı burada bitir: liste güncellemesi yeni bir task'ta çalışır, tarayıcı ondan önce paint edebilir
  protected async onInput(value: string): Promise<void> {
    await yieldToMain();
    this.query.set(value);
  }

scheduler.yield() browser-compat-data'ya göre Chrome 129 ve Firefox 142'de var, Safari'de yok; o yüzden setTimeout'a düşen bir fallback şart. Chrome ekibinin yazısındaki fark önemli: scheduler.yield()'ın devamı sıradaki diğer task'lardan önce çalışıyor, setTimeout(0)'ınki ise queue'nun sonuna gidiyor.

http://127.0.0.1:5300/books profile=mobile runs=5
run     TTFB    FCP    LCP    CLS    INP
1        152   1288   1288      0    128
2        150   1216   1216      0    128
3        151   1216   1216      0     64
4        151   1208   1208      0     64
5        151   1208   1208      0    120
med      151   1216   1216      0    120
LCP subparts (median): ttfb=151 loadDelay=0 loadDuration=0 renderDelay=1066
LCP element: html>body>app-root>main>app-catalog-page>h1
INP subparts (median): inputDelay=55 processing=2 presentation=61

INP 296 ms'den 120 ms'ye indi ve "good" sınırının altına geçti. Ama iş azalmadı: 300 ms'lik render hâlâ var, yalnız artık etkileşimin frame'inden sonra çalışıyor. Bunun izi input delay'de (ortanca 55 ms): hızlı yazan kullanıcının bir sonraki tuşu bu render'ın bitmesini bekliyor. Yield, kullanıcıya hemen geri bildirim vermek için iyi bir araç ama işin kendisini küçültmüyor.

Adım 3: İşi Azaltmak, CDK Virtual Scroll

Telefon ekranında aynı anda en fazla 20-25 satır görünüyor; 2.000 satırı DOM'da tutmanın tek getirisi tarayıcının hepsini layout etmesi. Angular CDK'nın (Component Dev Kit; Angular ekibinin component geliştirme araç seti) virtual scroll'u yalnız viewport'taki ve biraz çevresindeki satırları render ediyor, scroll ettikçe aynı DOM element'lerini yeni kitaplarla dolduruyor:

npm install @angular/cdk@22.2.7
  template: `
    <h1>Catalog</h1>
    <input type="search" class="filter" placeholder="Filter by title or author"
           (input)="onInput($any($event.target).value)" />
    <p>{{ filtered().length }} books</p>
    <!-- DOM'da yalnız viewport'taki (ve hemen çevresindeki) satırlar var -->
    <cdk-virtual-scroll-viewport itemSize="37" class="books">
      <div *cdkVirtualFor="let book of filtered(); trackBy: byIsbn" class="row">
        <a [routerLink]="['/books', book.isbn]">{{ book.title }}</a>
        <span>{{ book.author }}</span>
        <span>{{ book.publisher }}</span>
        <span>{{ book.year }}</span>
        <span class="price">{{ book.priceText }}</span>
        <span>{{ book.stock > 0 ? 'In stock' : 'Sold out' }}</span>
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styles: `
    .filter { width: 100%; padding: 10px 12px; font-size: 16px; }
    .books { height: 70vh; font-size: 14px; }
    .row { display: grid; grid-template-columns: 3fr 2fr 2fr 1fr 1fr 1fr; gap: 8px; height: 37px;
           align-items: center; padding: 0 8px; border-bottom: 1px solid #e2e8f0; overflow: hidden; }
    .price { text-align: right; white-space: nowrap; }
  `,
})
export class CatalogPage {
  protected readonly books = httpResource<Book[]>(() => '/api/books');
  protected readonly query = signal('');
  protected readonly byIsbn = (_: number, book: Book) => book.isbn;

Virtual scroll sabit yükseklikli satır istiyor (itemSize="37"); tablo yerine grid ile dizilmiş div'ler kullanıyoruz. Son hâl, virtual scroll ile yield birlikte:

http://127.0.0.1:5300/books profile=mobile runs=5
run     TTFB    FCP    LCP    CLS    INP
1        152   1404   1404      0     32
2        151   1332   1332      0      8
3        150   1332   1332      0     24
4        151   1336   1336      0      8
5        150   1340   1340      0      8
med      151   1336   1336      0      8
LCP subparts (median): ttfb=151 loadDelay=0 loadDuration=0 renderDelay=1186
LCP element: html>body>app-root>main>app-catalog-page>h1
INP subparts (median): inputDelay=1 processing=0 presentation=8
DurumINP (ms, ortanca)Not
A: başlangıç416template method'u, satır başına formatter, track $index
B: computed, tek formatter, track book.isbn296hesaplama gitti, DOM kaldı
D: B + scheduler.yield()120iş aynı, etkileşimin dışına çıktı
C: B + CDK virtual scroll24DOM 16.030'dan 193 element'e
E: C + scheduler.yield() (son hâl)8düşük seviye telefon profilinde 16

Sayfadaki element sayısı 16.030'dan 193'e indi ve en kötü etkileşim 8 ms. Bedeli de ölçtük: CDK'nın scrolling module'ü main.js'i 316 KB'tan 376 KB'a büyüttü ve katalog sayfasının FCP'si (First Contentful Paint; ilk içeriğin çizildiği an) 1,2 sn'den 1,34 sn'ye çıktı, çünkü katalog route'u lazy yüklenmiyor. Bunu JavaScript maliyeti yazısında lazy route'larla geri alacağız.

Diğer Araçlar ve Ne Zaman Kullanılırlar

Virtual scroll her liste için doğru değil. Tarayıcının sayfa içi araması (Ctrl+F) yalnız DOM'daki satırları bulur, ekran okuyucular listenin toplam uzunluğunu bilemez ve sabit satır yüksekliği her tasarıma uymaz. Daha hafif bir alternatif CSS'teki content-visibility: auto: element'ler DOM'da kalıyor ama ekran dışındakilerin layout ve paint'i atlanıyor (browser-compat-data'ya göre Chrome 85, Firefox 125, Safari 18); contain-intrinsic-size ile tahmini bir boyut vermek gerekiyor, yoksa scrollbar zıplıyor. Bunu bu yazıda ölçmedik.

Debounce, her tuşta değil kullanıcı yazmayı bırakınca süzmek demek; aramanın sunucuya gittiği durumlarda istek sayısını da düşürüyor. Temeli JavaScript İleri serisindeki "JavaScript Performans İpuçları: Debounce, Throttle ve Layout Thrashing" yazısında. Gerçekten ağır bir hesaplama (büyük bir veri setini sıralamak, bir PDF'i parse etmek) için doğru yer main thread değil bir Web Worker; o konu Browser API'leri ve PWA (Progressive Web App; kurulabilen, çevrimdışı çalışabilen web uygulaması) serisinde. Angular'a özgü bir kaynak da effect zincirleri: bir signal'ı başka bir signal'a effect içinde yazmak her değişiklikte fazladan bir change detection turu demek; türetilmiş değer için computed kullan.

Üretimde Dikkat

INP'yi RUM'da etkileşimin hedefine göre grupla. İlk yazıda gönderdiğimiz attribution.target (ör. input.filter) ve type (keyboard, pointer), "katalogdaki arama kutusu yavaş" demeyi mümkün kılıyor; yalnız route bazında bir p75 hangi düğmenin yavaş olduğunu söylemiyor. LoAF'tan gelen script bilgisini de sakla, ama üçüncü parti script'lerin adları ve URL'leri burada da görünür; RUM verisini kimlerin gördüğüne dikkat et.

Lab'da INP'yi yalnız senin yazdığın senaryo ölçüyor. Bu yazıdaki sorun kullanıcı aramayı sildiğinde çıktı, yalnız "war" yazdığında (ilk yazının simülasyonu) INP 232 ms idi. Gerçek kullanıcılar senaryoyu senden iyi bilir; field verisindeki en kötü etkileşimin hedefi, lab'da neyi denemen gerektiğini gösterir. Son olarak, Safari'de scheduler.yield() olmadığı için fallback'teki setTimeout'un devamı queue'nun sonuna gidiyor; yoğun sayfalarda liste güncellemesi orada biraz daha geç gelebilir.

Sık Düşülen Tuzaklar

  • Template'te pahalı bir method çağırmak. Her change detection'da yeniden çalışır; aynı template'te iki kez çağrılırsa iki kez. Türetilmiş değer için computed kullan.
  • track $index ile liste satırlarını konuma bağlamak ya da her yenilemede yeni object'ler gelen bir listede object'in kendisini izlemek.
  • Döngü içinde new Intl.NumberFormat, new Intl.DateTimeFormat ya da new RegExp kurmak.
  • Yalnız processing duration'a bakmak. Zoneless Angular'da asıl iş çoğu zaman bir frame callback'inde, yani presentation delay'de ya da bir sonraki etkileşimin input delay'inde görünür.
  • scheduler.yield()'ı fallback'siz kullanmak; Safari'de scheduler yok ve kod hata verir.
  • Yield'i işi azaltmanın yerine koymak. INP düşer ama long task yerinde durur.
  • INP'yi yalnız sayfa yüklenirken ölçmek. Tanımı gereği sayfanın bütün ömrünü kapsıyor.

Küçük Bir Deneme

★☆☆ measure.mjs'i son hâl için --type tolstoy --clear --profile lowend ile çalıştır. INP'nin üç fazından hangisi en büyük çıkıyor?

★★☆ B durumuna geri dön ve @for'daki track book.isbn'i track book yap. API'yi her aramada yeniden çağıran bir değişiklikle (ör. httpResource'a query'yi parametre olarak ver) INP'yi ölç; satırların yeniden kurulduğunu DevTools'un Elements panelinde gör.

★★★ Tabloyu koruyup tbody satırlarına content-visibility: auto ve contain-intrinsic-size: auto 33px ver. INP'yi virtual scroll'la karşılaştır ve Ctrl+F ile listenin sonundaki bir kitabı arayıp iki çözüm arasındaki farkı not et.

Katalogdaki INP 416 ms'den 8 ms'ye indi. Bir sonraki yazıda CLS'e (Cumulative Layout Shift; beklenmedik kaymaların toplam puanı) dönüyoruz: kaymanın nasıl hesaplandığını (impact ve distance, session window), kullanıcı girdisinden sonraki 500 ms istisnasını ve kitap detayındaki "Benzer kitaplar" bloğunun yüklenince sayfayı itmesini, @defer placeholder'larının boyutuyla birlikte ele alacağız.