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

Web Performansını Doğru Ölçmek: Lab vs Field, CrUX, p75 ve Angular'da web-vitals ile RUM

Mert Özen 15 Kas 2026 18 dk 5 görüntülenme
Web Performansını Doğru Ölçmek: Lab vs Field, CrUX, p75 ve Angular'da web-vitals ile RUM

Web performansı tek bir Lighthouse skoruyla değil, gerçek kullanıcıların LCP, INP ve CLS değerlerinin mobil ve masaüstü için ayrı ayrı 75. yüzdeliğiyle (p75) ölçülür. Lab ile field'ın neden farklı sonuç verdiğini, CrUX'in neyi topladığını, 2026'da hangi tarayıcının neyi ölçebildiğini anlatıyor; Bookstore'un Angular istemcisinde web-vitals 6 ile RUM toplayıp ASP.NET Core'da route bazında p75 ve alarm hesaplıyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (Angular 22.2'nin desteklediği TypeScript 6.0 ile), web-vitals 6.2.3, .NET 10 (ASP.NET Core 10), Lighthouse 13.6 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; field verisiyle ilgili kısımlar Chrome UX Report dokümanına dayanır. 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çı) Angular istemcisi bookstore-web'in ana sayfasını aynı makinede, aynı build'le, arka arkaya beş kez Lighthouse'tan geçiriyoruz. Arada tek satır kod değişmiyor:

node lighthouse.mjs http://127.0.0.1:5300/ --runs 5

Okumaya devam etmeden önce tahmin et: beş skor aynı mı çıkar? LCP (en büyük içeriğin çizildiği an) ile FCP'den (ilk içeriğin çizildiği an) hangisi koşudan koşuya daha çok oynar? Gerçek çıktı:

Lighthouse 5 runs, mobile: http://127.0.0.1:5300/
run  score    FCP    LCP    TBT    CLS     SI
1       64   3016  11927    217  0.033   4173
2       63   3010  11926    207  0.033   5107
3       65   2149  11926    232  0.033   5127
4       68   2154  11926    222  0.033   3328
5       69   2153  11926    208  0.033   3328
med     65   2154  11926    217  0.033   4173

Skor 63 ile 69 arasında oynadı. Daha ilginci, en çok oynayan metrik FCP oldu: iki koşuda 3,0 sn, üçünde 2,15 sn. LCP ise beş koşuda da 11,9 sn çıktı. Lighthouse varsayılan olarak sayfayı throttling olmadan yüklüyor, sonra yavaş bir telefonda ne olacağını bu gözlemden kurduğu bir modelle hesaplıyor; gözlemdeki küçük bir sıra farkı (font CSS'inin main.js'ten önce mi sonra mı bittiği gibi) hesaplanan değeri yüzlerce milisaniye kaydırabiliyor. Tek bir koşuya bakıp "değişikliğim 5 puan kazandırdı" demek bu yüzden yanıltıcı.

Bu yazı serinin temelini atıyor: hangi metriğe, hangi eşikle, hangi yüzdelikte bakılır; lab ölçümü ile gerçek kullanıcı verisi neden farklı sonuç verir; Chrome UX Report (CrUX) neyi toplar; ve Bookstore'un kendi gerçek kullanıcı ölçümünü (RUM) Angular'da web-vitals ile toplayıp ASP.NET Core'da route bazında p75'e nasıl çeviririz. DevTools'un Performance paneli ve Lighthouse'un kullanımı Browser DevTools serisinin konusu; burada araçtan çok ölçüm stratejisine bakıyoruz.

Metrikler ve Eşikleri

Google'ın Core Web Vitals (CWV) dediği üç metrik, kullanıcının sayfada yaşadığı üç ayrı şeyi ölçüyor. LCP (Largest Contentful Paint; viewport'taki en büyük görsel ya da metin bloğunun ekrana çizildiği an) yüklemeyi, INP (Interaction to Next Paint; bir tıklama ya da tuşa basıştan sonraki ilk paint'e kadar geçen süre, sayfadaki en kötü etkileşimlerden biri) tepkiselliği, CLS (Cumulative Layout Shift; beklenmedik kaymaların toplam puanı) görsel kararlılığı anlatıyor. Bunların yanında iki teşhis metriği var: TTFB (Time to First Byte; isteğin başlamasından HTML'in ilk byte'ının gelmesine kadar geçen süre) ve FCP (First Contentful Paint; ekrana ilk metnin ya da görselin çizildiği an). Bu ikisi CWV değil ama LCP'nin neden geç kaldığını anlamak için gerekiyor.

MetrikİyiKötüNe zaman kesinleşir
TTFB≤ 800 ms> 1.800 msHTML'in ilk byte'ı gelince
FCP≤ 1,8 sn> 3,0 snilk paint'te
LCP≤ 2,5 sn> 4,0 snilk kullanıcı girdisinde ya da sayfa gizlenince
INP≤ 200 ms> 500 mssayfa gizlenince (ömür boyu)
CLS≤ 0,1> 0,25sayfa gizlenince (ömür boyu)

Aradaki bölge "needs improvement". Eşikler web.dev'deki metrik sayfalarından; değerlendirme tek bir ölçümle değil, bir sayfanın bütün yüklemelerinin 75. yüzdeliğiyle (p75) ve mobil ile masaüstü ayrı ayrı yapılıyor. Bir sayfa ancak üç CWV metriğinde de p75'te "good" ise geçmiş sayılıyor.

01-metrics-timeline-tr.png

Tablonun son sütunu RUM kodunu yazarken önemli. TTFB ve FCP sayfa açılır açılmaz belli oluyor. LCP, kullanıcı ilk kez tıklayana ya da bir tuşa basana kadar büyüyebiliyor; sonrasında tarayıcı yeni aday raporlamıyor. INP ve CLS ise sayfanın bütün ömrü boyunca birikiyor ve ancak kullanıcı sekmeyi kapatınca ya da başka sekmeye geçince son değerine ulaşıyor. Bu yüzden ölçüm kodu "sayfa yüklendi, gönder" diyemez; veriyi sayfa gizlenirken göndermek zorunda.

Neden Ortalama Değil, p75?

Performans verisi normal dağılmaz: çoğu ziyaret hızlıdır, küçük bir kısmı ise çok yavaştır ve uzun bir kuyruk oluşturur. Ortalama bu kuyruğu ya gizler ya da birkaç aşırı değerle bozulur. Ortanca (medyan, p50) "tipik" kullanıcıyı anlatır ama kullanıcıların yarısını yok sayar. p75, "dört kullanıcıdan üçü en az bu kadar iyi deneyim yaşadı" demek; yavaş cihazları hesaba katacak kadar kötümser, tek bir aşırı değerle oynamayacak kadar da sağlam.

Aşağıda Bookstore ana sayfasının LCP'sini, yazının sonunda kuracağımız lab simülasyonundan gelen oturumlarla görüyorsun. Masaüstü ve telefon oturumları ayrı:

LCP on / (desktop, n=6): mean=2764 p50=2760 p75=2780 max=2804
LCP on / (mobile, n=9): mean=22989 p50=16216 p75=36548 max=36572

Masaüstünde dağılım dar: ortalama, ortanca ve p75 aynı yerde. Telefon tarafı ise iki gruptan oluşuyor: altı orta seviye telefon 16 sn civarında, üç düşük seviye telefon 36 sn civarında. Ortalama (23,0 sn) hiçbir kullanıcının yaşamadığı bir değer. Ortanca (16,2 sn) düşük seviye telefonları hiç görmüyor. p75 (36,5 sn) ise kullanıcıların dörtte birini oluşturan yavaş grubu tabloya taşıyor. Gerçek trafikte gruplar bu kadar keskin ayrılmaz ama mantık aynı: p75'i iyileştirmek için çoğu zaman en yavaş cihazların yaşadığına bakman gerekir.

Lab ve Field Neden Farklı Sonuç Verir?

Lab ölçümü, sabit koşullarda (belli bir cihaz, ağ hızı, boş cache) tek bir sayfa yüklemesini ölçer: tekrarlanabilir, hata ayıklamaya uygun ama tek bir senaryo. Field verisi gerçek kullanıcılardan gelir: her cihaz, her ağ, dolu ya da boş cache, sayfada geçirilen bütün süre. İkisinin farklı çıkması normal. Field'da düşük seviye telefonlar ve kötü bağlantılar var; geri dönen kullanıcının cache'i dolu; viewport boyutu, giriş yapmış kullanıcıya özel içerik ya da bir cookie banner'ı LCP element'ini değiştirebiliyor. INP bir etkileşim gerektiriyor ve Lighthouse sayfaya dokunmadığı için onun yerine ilişkili TBT'yi (Total Blocking Time; FCP'den sonra main thread'i 50 ms'den uzun bloklayan task'ların fazlalık süresi) raporluyor. CLS ve INP field'da sayfanın bütün ömrünü kapsıyor; lab yalnız yüklemeye bakıyor, scroll sırasındaki kaymaları görmüyor.

Lighthouse varsayılan olarak "simulated throttling" kullanır: sayfayı hızlı yükler, sonra yavaş bir mobil cihazda ne olacağını bir modelle hesaplar. Kendi lab ölçümlerimiz için Playwright ile Chromium'u başlatıp throttling'i Chrome DevTools Protocol (CDP; DevTools'un tarayıcıyı yönettiği protokol) üzerinden gerçekten uygulayan küçük bir script yazdık. Ölçüm değerlerini de Lighthouse'tan değil, uygulamanın kendi RUM kodundan okuyor; yani lab'da ve field'da aynı kod ölçüyor:

// Throttling profilleri (DevTools'un yaptığı gibi CDP ile uygulanır)
export const profiles = {
  desktop: { device: null, cpu: 1, latency: 40, downKbps: 10_000, upKbps: 5_000 },
  mobile: { device: 'Pixel 7', cpu: 4, latency: 150, downKbps: 1_600, upKbps: 750 },
  lowend: { device: 'Pixel 7', cpu: 6, latency: 300, downKbps: 700, upKbps: 400 },
};

Aynı ana sayfa, üç profil, profil başına beş koşu (ortanca son satırda):

$ node measure.mjs http://127.0.0.1:5300/ --runs 5 --profile desktop
run     TTFB    FCP    LCP    CLS
med       41    480   2756  0.023
LCP subparts (median): ttfb=41 loadDelay=481 loadDuration=2216 renderDelay=11

$ node measure.mjs http://127.0.0.1:5300/ --runs 5 --profile mobile
run     TTFB    FCP    LCP    CLS
1        151   2352  16164 0.0318
2        151   2344  16208 0.0318
3        151   2360  16224 0.0318
4        151   2356  16204 0.0318
5        151   2368  16204 0.0318
med      151   2356  16204 0.0318
LCP subparts (median): ttfb=151 loadDelay=2364 loadDuration=13677 renderDelay=6
LCP element: html>body>app-root>main>app-home-page>section.hero>div.hero-cover

$ node measure.mjs http://127.0.0.1:5300/ --runs 5 --profile lowend
run     TTFB    FCP    LCP    CLS
med      301   5220  36552 0.0318
LCP subparts (median): ttfb=301 loadDelay=5241 loadDuration=31017 renderDelay=8

Aynı kod masaüstü profilinde 2,8 sn, orta seviye telefonda 16,2 sn, düşük seviye telefonda 36,6 sn LCP veriyor. Alt parçalar sebebi de söylüyor: LCP görseli (öne çıkan kitabın kapağı) sayfa açıldıktan 2,4 sn sonra keşfediliyor ve 13,7 sn boyunca iniyor, çünkü ana sayfa yaklaşık 450 KB'lık dokuz kapak görselini aynı anda istiyor. Bunu 03 ve 04'te düzelteceğiz. İki şeye daha dikkat et. Birincisi, kendi script'imizde beş koşu birbirine 60 ms'den yakın; yerel sunucu ve gerçekten uygulanan throttling tekrarlanabilir sonuç veriyor. İkincisi, iki lab aracı aynı sayfa için aynı sayıyı vermiyor: Lighthouse'un modeli LCP'yi 11,9 sn hesaplarken bizim profil 16,2 sn ölçtü. Cihaz (Lighthouse Moto G Power ekranını, biz Pixel 7'yi taklit ediyoruz) ve throttling yöntemi farklı. Lab sayıları mutlak doğrular değil, aynı araçla önce/sonra karşılaştırmak için.

CrUX: Google'ın Field Verisi

Chrome UX Report (CrUX), kullanım istatistiği paylaşımını açmış Chrome kullanıcılarından toplanan, herkese açık bir field veri seti. Bu yazıda CrUX'e istek atmadık (Bookstore'un yerel build'i CrUX'te zaten yok); aşağıdakiler CrUX dokümanından:

  • CrUX API'deki veri, son 28 günün kayan toplamı; her gün güncelleniyor. BigQuery'deki veri seti ise aylık yayımlanıyor.
  • Bir sayfa ya da origin ancak herkese açık ve yeterince popülerse veri setine giriyor; trafiği az olan sayfa için CrUX'te veri olmayabilir.
  • Metrikler histogram ve p75 olarak, telefon, masaüstü ve tablet için ayrı veriliyor. LCP'nin alt parçaları da (TTFB, resource load delay, load duration, render delay) ayrı ayrı p75 olarak var; bunlar ayrı ayrı yüzdelik olduğu için toplamları LCP'nin p75'ine eşit çıkmıyor.
  • Yalnız Chrome kullanıcıları: Safari ve Firefox kullanıcılarının deneyimi CrUX'te yok.

CrUX'in en büyük eksiği ayrıntı: 28 günlük bir toplam, dün yaptığın deploy'un etkisini haftalar sonra ve sitenin geneline karışmış olarak gösteriyor; hangi route'un, hangi element'in kötü olduğunu söylemiyor. Bunun için kendi RUM'unu kurman gerekiyor. Search Console'daki CWV raporu ve sıralamaya etkisi Analytics ve Ölçüm serisinin konusu.

2026 Sonu: Hangi Tarayıcı Neyi Ölçebiliyor?

Birkaç yıl öncesine kadar CWV'yi yalnız Chromium tabanlı tarayıcılar ölçebiliyordu. MDN browser-compat-data 8.1.9'a göre tablo artık şöyle:

APIChromeFirefoxSafari
LargestContentfulPaint (LCP)7712226.2
Event Timing interactionId (INP)9614426.2
LayoutShift (CLS)77yokyok
PerformanceLongAnimationFrameTiming (LoAF)123yokyok
PerformanceSoftNavigation151 (deneysel)yokyok

LoAF (Long Animation Frames), uzun animasyon frame'lerini script bazında raporlayan API; INP'nin sebebini bulurken 05'te kullanacağız.

Pratik sonucu şu: Safari ve Firefox kullanıcılarından artık LCP ve INP topluyorsun ama CLS gelmiyor. RUM tablosunda CLS'in sample sayısı LCP'ninkinden az çıkarsa sebep bu; tarayıcıya göre ayırmadan CLS'i LCP ile aynı paydada yorumlama.

İkinci büyük değişiklik SPA'lar (Single-Page Application; sayfa değiştirirken yeni HTML yüklemeyen uygulamalar) için. Angular gibi bir SPA'da kullanıcı katalogdan kitap detayına geçtiğinde yeni bir HTML yüklenmiyor; klasik ölçümde bütün oturum ilk açılan sayfaya yazılıyor. Chrome 151 ile gelen Soft Navigations API, "kullanıcı etkileşimi + URL değişimi + ekrana bir şey çizilmesi" üçlüsünü bir navigation sayıyor. web.dev'in 13 Ekim 2026 tarihli duyurusuna göre CrUX, Ekim 2026 verisinden itibaren soft navigation'ları da içeren deneysel LCP, INP ve CLS metrikleri yayımlıyor; stabil CWV değerleri değişmedi ve deneysel metriklerin Mayıs 2027'de stabil hale gelmesi hedefleniyor. web-vitals 6 bunu reportSoftNavs: true seçeneğiyle destekliyor ve her metriğe navigationURL ekliyor. Biz bu yazıda klasik ölçümle başlıyoruz: bütün metrikler ilk açılan sayfanın route'una yazılıyor.

Angular'da web-vitals ile Metrik Toplamak

web-vitals, Google'ın Chrome ekibinin CrUX'le aynı tanımlarla metrik hesaplayan küçük library'si. İki build'i var; "attribution" build'i her metriğe sebebini de ekliyor: LCP için element ve alt parçaları, INP için etkileşimin hedefi ve üç fazı, CLS için en büyük kaymanın element'i. Kurulum:

npm install web-vitals@6.2.3

RUM kodunu bir provider olarak yazıyoruz; böylece app.config.ts'te tek satırla açılıp kapanıyor:

import { DOCUMENT, EnvironmentProviders, inject, makeEnvironmentProviders, provideAppInitializer } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter, take } from 'rxjs';
import { onCLS, onFCP, onINP, onLCP, onTTFB, type MetricWithAttribution } from 'web-vitals/attribution';

export interface RumOptions {
  endpoint: string;
  sampleRate: number;
}

type Attribution = Record<string, string | number | undefined>;

interface RumReport {
  name: MetricWithAttribution['name'];
  value: number;
  rating: MetricWithAttribution['rating'];
  route: string;
  device: 'mobile' | 'desktop';
  navigationType: MetricWithAttribution['navigationType'];
  attribution: Attribution;
}

export function provideRum(options: RumOptions): EnvironmentProviders {
  return makeEnvironmentProviders([
    provideAppInitializer(() => {
      // 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));
      }
    }),
  ]);
}

function startRum(options: RumOptions, router: Router, document: Document): void {
  const queue: Omit<RumReport, 'route'>[] = [];
  const device = /Mobi|Android/i.test(navigator.userAgent) ? 'mobile' : 'desktop';
  // Soft navigation raporlanmadığında bütün metrikler ilk açılan sayfaya aittir
  let landingRoute = '(unknown)';
  router.events
    .pipe(filter((e) => e instanceof NavigationEnd), take(1))
    .subscribe(() => (landingRoute = routePattern(router)));

  const add = (metric: MetricWithAttribution) =>
    queue.push({
      name: metric.name,
      value: round(metric.value, metric.name === 'CLS' ? 4 : 0),
      rating: metric.rating,
      device,
      navigationType: metric.navigationType,
      attribution: pickAttribution(metric),
    });

  onTTFB(add);
  onFCP(add);
  onLCP(add);
  onINP(add);
  onCLS(add);

  const flush = () => {
    if (queue.length === 0) return;
    const body = JSON.stringify(queue.splice(0).map((r) => ({ ...r, route: landingRoute })));
    navigator.sendBeacon(options.endpoint, new Blob([body], { type: 'application/json' }));
  };
  // "hidden" güvenebileceğimiz son an (sekme değişimi, uygulama değişimi, sayfadan çıkış)
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') flush();
  });
}

Üç karar dikkat istiyor. Birincisi sampling: karar sayfa görüntüleme başına bir kez veriliyor, metrik başına değil; yoksa aynı ziyaretin LCP'si gidip CLS'i gitmeyebilir. İkincisi gönderme anı: metrikleri bir queue'da biriktirip visibilitychange ile sayfa gizlenirken tek bir beacon'la gönderiyoruz. unload ve beforeunload mobilde güvenilir şekilde tetiklenmiyor, unload ayrıca bfcache'i bozuyor; navigator.sendBeacon ise sayfa kapanırken bile isteği tarayıcıya teslim ediyor. Üçüncüsü route: URL yerine Angular Router'ın eşleşen route pattern'ini gönderiyoruz, yoksa 2.000 kitabın her biri ayrı bir satır olur ve hiçbirinde p75 hesaplayacak kadar sample birikmez.

Attribution verisinin hepsi gönderilmez; performance entry'leri büyük ve DOM (sayfanın tarayıcıdaki ağaç yapısı) referansları içeriyor. Teşhis için gereken birkaç alanı seçiyoruz:

// '/books/9781000000371' yerine '/books/:isbn': p75 URL başına değil, route başına hesaplanır
function routePattern(router: Router): string {
  const parts: string[] = [];
  let node = router.routerState.snapshot.root.firstChild;
  while (node) {
    if (node.routeConfig?.path) parts.push(node.routeConfig.path);
    node = node.firstChild;
  }
  return '/' + parts.join('/');
}

function pickAttribution(metric: MetricWithAttribution): Attribution {
  switch (metric.name) {
    case 'LCP':
      return {
        target: metric.attribution.target,
        url: metric.attribution.url,
        ttfb: round(metric.attribution.timeToFirstByte),
        loadDelay: round(metric.attribution.resourceLoadDelay),
        loadDuration: round(metric.attribution.resourceLoadDuration),
        renderDelay: round(metric.attribution.elementRenderDelay),
      };
    case 'INP':
      return {
        target: metric.attribution.interactionTarget,
        type: metric.attribution.interactionType,
        inputDelay: round(metric.attribution.inputDelay),
        processing: round(metric.attribution.processingDuration),
        presentation: round(metric.attribution.presentationDelay),
        script: metric.attribution.longestScript?.entry.invoker,
      };
    case 'CLS':
      return {
        target: metric.attribution.largestShiftTarget,
        largestShift: round(metric.attribution.largestShiftValue ?? 0, 4),
      };
    default:
      return {};
  }
}

function round(value: number, digits = 0): number {
  const f = 10 ** digits;
  return Math.round(value * f) / f;
}
export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(),
    // Lab: her ziyaret ölçülüyor; üretimde trafiğe göre sampling yap (ör. 0.1)
    provideRum({ endpoint: '/api/rum', sampleRate: 1 }),
  ],
};

ASP.NET Core'da /api/rum ve Route Bazında p75

Bookstore'un API'si Angular build çıktısını da sunuyor; bu yüzden beacon aynı origin'e gidiyor ve CORS (tarayıcının başka origin'lere yapılan isteklere uyguladığı izin kontrolü) gerekmiyor. Endpoint, raporları doğrulayıp bellekte bir queue'ya koyuyor:

namespace Bookstore.Api.Rum;

public static class RumEndpoints
{
    private const int MaxReportsPerBeacon = 20;

    public static IEndpointRouteBuilder MapRumEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapPost("/api/rum", Results<NoContent, BadRequest> (RumReport[] reports, RumStore store) =>
        {
            if (reports.Length is 0 or > MaxReportsPerBeacon)
            {
                return TypedResults.BadRequest();
            }
            foreach (RumReport report in reports)
            {
                // Bilinmeyen metrikleri ve anlamsız değerleri saklamadan geç
                if (RumThresholds.IsKnown(report.Name) && report.Value is >= 0 and < 120_000 && report.Route.Length <= 100)
                {
                    store.Add(report);
                }
            }
            return TypedResults.NoContent();
        });

        app.MapGet("/api/rum/p75", (RumStore store, int? minutes, int? minSamples) =>
            TypedResults.Ok(store.P75(TimeSpan.FromMinutes(minutes ?? 60), minSamples ?? 20)));

        return app;
    }
}
namespace Bookstore.Api.Rum;

// web.dev'deki "good" ve "poor" sınırları; değerlendirme 75. yüzdelikte yapılır
public static class RumThresholds
{
    private static readonly Dictionary<string, (double Good, double Poor)> Limits = new()
    {
        ["TTFB"] = (800, 1800),
        ["FCP"] = (1800, 3000),
        ["LCP"] = (2500, 4000),
        ["INP"] = (200, 500),
        ["CLS"] = (0.1, 0.25),
    };

    public static bool IsKnown(string metric) => Limits.ContainsKey(metric);

    public static string Rate(string metric, double value)
    {
        (double good, double poor) = Limits[metric];
        return value <= good ? "good" : value <= poor ? "needs-improvement" : "poor";
    }
}
using System.Collections.Concurrent;

namespace Bookstore.Api.Rum;

public sealed class RumStore(TimeProvider clock)
{
    private const int MaxSamples = 100_000;
    private readonly ConcurrentQueue<RumSample> _samples = new();

    public void Add(RumReport report)
    {
        _samples.Enqueue(new RumSample(report, clock.GetUtcNow()));
        // Bellek sınırlı kalsın: sonsuza kadar büyümek yerine en eski sample'ları at
        while (_samples.Count > MaxSamples && _samples.TryDequeue(out _))
        {
        }
    }

    public IReadOnlyList<RumRow> P75(TimeSpan window, int minSamples)
    {
        DateTimeOffset since = clock.GetUtcNow() - window;
        return _samples
            .Where(s => s.ReceivedAt >= since)
            .GroupBy(s => (s.Report.Route, s.Report.Name, s.Report.Device))
            .Select(g =>
            {
                double[] values = g.Select(s => s.Report.Value).Order().ToArray();
                double p75 = Percentile(values, 0.75);
                string rating = RumThresholds.Rate(g.Key.Name, p75);
                bool alarm = values.Length >= minSamples && rating != "good";
                return new RumRow(g.Key.Route, g.Key.Name, g.Key.Device, values.Length, Math.Round(p75, 3), rating, alarm);
            })
            .OrderBy(r => r.Route).ThenBy(r => r.Metric).ThenBy(r => r.Device)
            .ToList();
    }

    // Sıralı array üzerinde nearest-rank yüzdelik
    private static double Percentile(double[] sorted, double p)
    {
        int rank = (int)Math.Ceiling(p * sorted.Length);
        return sorted[Math.Clamp(rank - 1, 0, sorted.Length - 1)];
    }
}

p75'i hesaplarken route, metrik ve cihaz sınıfına göre grupluyoruz; CrUX'in telefon ve masaüstünü ayırmasıyla aynı mantık. Alarm, yalnız yeterli sample varsa çalıyor: üç ziyaretlik bir route'un p75'i yüzünden kimseyi gece uyandırmak istemezsin.

Veri olmadan bu tablo boş kalır. Gerçek kullanıcımız olmadığı için lab'da küçük bir ziyaretçi karışımı simüle ettik: her route için 6 masaüstü, 6 orta seviye telefon ve 3 düşük seviye telefon oturumu. Katalog oturumları arama kutusuna "war" yazıyor, diğerleri sayfayı açıp çıkıyor. Bunların gerçek kullanıcı verisi olmadığını, yalnız RUM hattını beslemek için üretildiğini unutma:

const routes = [
  { path: '/', act: async () => {} },
  { path: '/books', act: async (page) => page.locator('input[type=search]').pressSequentially('war', { delay: 120 }) },
  { path: '/books/9781000000009', act: async () => {} },
];
// Ziyaretçi karışımı: route başına 6 masaüstü, 6 orta seviye telefon, 3 düşük seviye telefon oturumu
const mix = [['desktop', 6], ['mobile', 6], ['lowend', 3]];
$ node field-sim.mjs
.............................................
225 metric reports sent to http://127.0.0.1:5300/api/rum

Sonuç, API'nin p75 endpoint'inden:

$ curl -s "http://127.0.0.1:5300/api/rum/p75?minSamples=5" | jq -r '.[] | select(.metric != "TTFB" and .metric != "FCP") | [.route, .metric, .device, .samples, .p75, .rating, .alarm] | @tsv'
/	CLS	desktop	6	0.021	good	false
/	CLS	mobile	9	0.032	good	false
/	INP	desktop	6	0	good	false
/	INP	mobile	9	8	good	false
/	LCP	desktop	6	2780	needs-improvement	true
/	LCP	mobile	9	36548	poor	true
/books	CLS	desktop	6	0	good	false
/books	CLS	mobile	9	0	good	false
/books	INP	desktop	6	48	good	false
/books	INP	mobile	9	232	needs-improvement	true
/books	LCP	desktop	6	780	good	false
/books	LCP	mobile	9	5228	poor	true
/books/:isbn	CLS	desktop	6	0	good	false
/books/:isbn	CLS	mobile	9	0.32	poor	true
/books/:isbn	INP	desktop	6	0	good	false
/books/:isbn	INP	mobile	9	0	good	false
/books/:isbn	LCP	desktop	6	976	good	false
/books/:isbn	LCP	mobile	9	10992	poor	true

Tablo, lab profillerinden beklediğimizi gösteriyor ama route bazında: ana sayfanın LCP'si her iki cihaz sınıfında alarmda; katalogda (/books) telefonların INP'si 232 ms ile "needs improvement" (2.000 kitaplık listede arama kutusuna yazmak yavaş, bunu 05'te düzelteceğiz); kitap detayında ise telefonda CLS 0,32 ile kötü. Bu sonuncusunu lab ölçümlerinde hiç görmemiştik, çünkü yalnız ana sayfayı ölçüyorduk: boyutsuz kapak görseli yüklenince altındaki metni itiyor. INP satırlarındaki 0 ve 8 ms gibi değerler ise simülasyondan geliyor; o oturumlarda tek etkileşim, ziyareti bitirirken basılan Shift tuşu. RUM'un değeri de bu: kimsenin ölçmeyi düşünmediği sayfadaki sorunu gösteriyor.

Üretimde Dikkat

Sampling oranını trafiğe göre seç. Günde birkaç bin sayfa görüntülemesi olan bir site her ziyareti ölçebilir; yüz binlerce görüntülemede %5-10'luk bir sample, p75'i anlamlı tutmaya yetecek kadar veri üretir ve API'yi yormaz. Oranı değiştirdiğinde eski ve yeni verinin karışmadığından emin ol.

Beacon endpoint'i herkese açık bir yazma kapısı. Gövde boyutunu, rapor sayısını ve değer aralığını sınırla, bilinmeyen metrikleri at, gerekirse ASP.NET Core'un rate limiting middleware'iyle IP başına sınır koy. Kişisel veri gönderme: URL yerine route pattern'i göndermemizin bir sebebi de query string'deki e-posta, token ya da arama terimleri. Attribution'daki CSS selector'leri de kullanıcı adı ya da ID içerebilir; sunucuda kısalt.

Bellekteki queue bir başlangıç: uygulama yeniden başlayınca veri gidiyor ve birden çok instance'ta her biri kendi payını görüyor. Gerçek kullanımda raporları bir time-series veritabanına ya da log/metrics hattına yaz, p75'i orada hesapla. Observability serisindeki araçlar bunun için uygun. Son olarak, navigationType'ı da sakla: bfcache'ten dönen ya da prerender edilmiş sayfaların değerleri normal yüklemelerden çok farklı ve aynı dağılımda karışınca tabloyu yanıltıyor.

Sık Düşülen Tuzaklar

  • Tek bir Lighthouse koşusuyla karar vermek. Aynı build'de bile skor birkaç puan oynuyor; en az 3-5 koşunun ortancasına bak ve değişikliği önce/sonra aynı koşullarda ölç.
  • Lab skorunu hedef saymak. Lighthouse 100 olan bir sayfanın field p75'i kötü olabilir; hedef field'daki p75, lab bunun sebebini bulmak için.
  • RUM'u sampling yapmadan bütün trafiğe açmak ve her metrik için ayrı istek göndermek. Sayfa görüntüleme başına beş istek, ölçmek istediğin performansı bozar ve API'yi yorar.
  • Metrikleri load event'inde göndermek. INP ve CLS o anda daha başlamadı bile; visibilitychange ile gizlenirken gönder.
  • URL başına p75 hesaplamak. Kitap detayının her ISBN'i (kitabın uluslararası standart numarası) ayrı satır olur ve hiçbirinde anlamlı bir yüzdelik çıkmaz.
  • Mobil ile masaüstünü aynı dağılımda toplamak. Masaüstünün iyi değerleri telefonun sorununu gizler.
  • Safari ve Firefox'tan CLS gelmemesini "CLS sorunu yok" diye okumak.

Küçük Bir Deneme

★☆☆ lighthouse.mjs'i --runs 10 ile çalıştır ve en iyi ile en kötü skor arasındaki farkı not et. Sonra measure.mjs'i aynı sayfa için --runs 10 --profile mobile ile çalıştır; hangisinin LCP'si koşudan koşuya daha az oynuyor?

★★☆ RumStore'a bir browser boyutu ekle: istemci navigator.userAgent'tan kaba bir tarayıcı adı (chromium, firefox, safari) göndersin, p75 endpoint'i bunu da gruplasın. Playwright'ın Firefox ve WebKit tarayıcılarıyla birkaç oturum açıp hangi metriklerin gelmediğini gör.

★★★ provideRum'a reportSoftNavs seçeneği ekle ve metrikleri ilk sayfanın route'u yerine metric.navigationURL'den türetilen route'a yaz. Ana sayfadan bir kitap detayına tıklayan bir lab oturumu yaz ve detay sayfası için ayrı bir LCP satırının oluştuğunu doğrula.

Elimizde artık route bazında bir p75 tablosu var ve ana sayfanın LCP'si kırmızı. Bir sonraki yazıda bu sayfanın HTML, CSS ve JavaScript'inin ekrana nasıl ulaştığına, yani critical rendering path'e bakacağız: tarayıcının neyi beklediğini bir waterfall'da okuyup ilk paint'i geciktiren iki kaynağı, üçüncü parti font CSS'ini ve analytics script'ini bulup düzelteceğiz.