Mert Özen Her Satırda Daha İleri
SEO ve GEO Analytics ve Ölçüm — #06

Core Web Vitals'ın SEO Etkisi: Google Ne Diyor, CrUX Field Data ve web-vitals ile Kendi RUM'unu Kurmak

Mert Özen 15 Kas 2026 12 dk 5 görüntülenme
Core Web Vitals'ın SEO Etkisi: Google Ne Diyor, CrUX Field Data ve web-vitals ile Kendi RUM'unu Kurmak

Google'a göre Core Web Vitals ranking system'lerde kullanılır ama sıralama garantisi değildir ve relevance önce gelir; değerlendirme Lighthouse skoruyla değil, gerçek kullanıcılardan gelen field data ile yapılır. Eşikleri ve 75. persentili, CrUX'in kimi kapsadığını, Search Console raporunun URL gruplarını, C# ile CrUX API'yi ve Search Console mantığıyla p75 hesaplayan bir ASP.NET Core RUM endpoint'ini anlatıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10), Angular 22.2 (TypeScript 6.0), web-vitals 6.2.3 ve Playwright 1.65'in Chromium'u ile denenmiştir; Chrome UX Report API'ye istek gönderilmedi. Bu yazıda resmî belgelerden yararlanılmıştır: Google Search Central'ın Core Web Vitals ve page experience sayfaları, web.dev, Chrome UX Report belgeleri ve Search Console Help, 15 Kasım 2026'daki hâliyle.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) blog'unda Ayşe, bir yazıyı Lighthouse ile mobil profilde test ediyor ve performans skoru kırmızı çıkıyor. Ekipte SEO (arama motoru optimizasyonu) açısından iki görüş var: "Bu skorla sıralamamız düşer, hemen düzeltelim" ve "Skor lab ölçümü, Google bunu kullanmıyor". Üstelik PageSpeed Insights'ın üst kısmı bu URL için gerçek kullanıcı verisi olmadığını söylüyor. Sence Lighthouse skoru sıralamayı etkiler mi? Gerçek kullanıcı verisi yoksa Core Web Vitals'ın durumunu nereden öğrenirsin?

Google Ne Diyor?

Google'ın Search Central'daki Core Web Vitals sayfası, Search'te başarı için site sahiplerine iyi Core Web Vitals değerlerine ulaşmayı güçlü biçimde öneriyor ve bu metric'lerin, page experience'ın diğer yönleriyle birlikte, core ranking system'lerin ödüllendirmek istediğiyle örtüştüğünü söylüyor. Page experience sayfasının sık sorulan soruları ise çerçeveyi netleştiriyor:

  • Tek bir page experience sinyali yok; ranking system'ler birlikte page experience'a karşılık gelen çeşitli sinyallere bakıyor.
  • Core Web Vitals ranking system'ler tarafından kullanılıyor; page experience'ın diğer yönleri sıralamaya doğrudan katkı yapmıyor ama kullanıcı memnuniyeti için önemli.
  • İyi Core Web Vitals sonuçları sayfanın en üstte sıralanacağını garanti etmiyor; Google yalnız SEO için mükemmel skor peşinde koşmayı önermiyor.
  • Relevance önce geliyor: Google, page experience zayıf olsa bile en alakalı içeriği göstermeye çalışıyor. İyi page experience, çok sayıda faydalı sayfanın aynı sorgu için yarıştığı durumlarda fark yaratıyor.
  • Değerlendirme genelde sayfa bazında, ama Google bazı site geneli değerlendirmeleri de olduğunu söylüyor.

Lighthouse skoru bu çerçevede geçmiyor. Google, Lighthouse'u page experience'ı iyileştirmek için bir araç seti olarak anıyor; skorun kendisinin bir sinyal olduğunu söyleyen bir ifade yok. Baştaki ilk sorunun dürüst cevabı bu: kırmızı bir Lighthouse skoru bir sorun olduğunu gösterir, ama sıralamayı etkileyen şey Google'ın kendi dokümanlarına göre Core Web Vitals'ın kendisi; o da gerçek kullanıcılardan ölçülüyor.

Eşikler ve 75. Persentil

Üç metric ve iyi sayılma eşikleri: LCP (Largest Contentful Paint; sayfadaki en büyük içeriğin görünme süresi) en fazla 2,5 saniye, INP (Interaction to Next Paint; bir etkileşime sayfanın tepki süresi) en fazla 200 ms, CLS (Cumulative Layout Shift; içeriğin beklenmedik kaymalarının toplamı) en fazla 0,1. Search Console'un Core Web Vitals raporu ikinci sınırı da veriyor: LCP için 4 saniye, INP için 500 ms, CLS için 0,25'in üstü "Poor", arası "Need improvement". INP 12 Mart 2024'te FID'in (First Input Delay; ilk etkileşimin gecikmesi) yerini aldı. Değerlendirme mobil ve desktop ayrı ayrı, sayfa yüklemelerinin 75. persentilinde yapılıyor: bir sayfa, ziyaretlerin dörtte üçü bu değerlerde ya da daha iyisindeyse geçiyor. web.dev'e göre stable metric'ler yılda en fazla bir kez değişiyor; yani eşiklere her ay yeniden bakmana gerek yok.

Field Data ve Lab Data

06-field-lab-tr.png

Field data gerçek ziyaretlerden geliyor. Google'ın kaynağı CrUX (Chrome UX Report; uygun Chrome kullanıcılarından toplanan gerçek deneyim verisi). Metodoloji sayfasına göre yalnız kullanım istatistiği paylaşımını açmış, tarama geçmişini senkronize eden ve senkronizasyon parolası olmayan kullanıcılar dahil; platformlar Windows, macOS, ChromeOS ve Linux'taki desktop Chrome ile Android'deki Chrome. iOS'taki Chrome, Android WebView'leri ve Edge gibi diğer Chromium tarayıcılar yok. Bir sayfa ya da origin'in CrUX'e girmesi için herkese açık keşfedilebilir olması, yani 200 dönmesi ve noindex taşımaması, ve yeterince popüler olması gerekiyor; eşik açıklanmıyor. Veri 28 günlük kayan bir pencere.

Lab data ise tek bir cihazda, simüle edilmiş ağ ve CPU ile yapılan bir test: Lighthouse ve DevTools. web.dev'in hatırlattığı gibi Lighthouse'da kullanıcı girdisi olmadığı için INP ölçülemiyor ve yerine TBT (Total Blocking Time; ana thread'in kilitli kaldığı toplam süre) kullanılıyor. PageSpeed Insights ikisini aynı sayfada gösteriyor: üst kısımda URL ya da origin için CrUX, alt kısımda Lighthouse.

Search Console'un Core Web Vitals raporu CrUX verisini URL gruplarıyla gösteriyor. Benzer sayfalar bir grupta toplanıyor ve grubun durumu en kötü metric'i: CLS'i kötü, INP'si iyi bir grup "Poor". Değerler 75. persentil ve cihaza göre ayrı. Bir grubun görünmesi için hem LCP hem CLS için yeterli veri gerekiyor; yetmezse Search Console origin seviyesine düşüyor, o da yetmezse hiçbir şey göstermiyor. Bir düzeltmeyi doğrulamak için "Start Tracking" 28 günlük bir izleme başlatıyor. Baştaki ikinci sorunun cevabı burada: trafiği az bir sayfa ya da yeni bir site için CrUX'te veri olmayabilir; o zaman elindeki tek field data kendi RUM'un (Real User Monitoring; kendi ziyaretçilerinden topladığın ölçüm).

CrUX API'yi C# ile Okumak

CrUX'i bir dashboard'a ya da haftalık rapora almak için CrUX API var: records:queryRecord endpoint'ine origin ya da URL, PHONE, DESKTOP veya TABLET form factor ve istenen metric'leri gönderiyorsun. OAuth yok, bir API key gerekiyor; kota proje başına dakikada 150 sorgu ve veri her gün UTC (koordineli evrensel zaman) 04:00 civarında güncelleniyor. Dikkat edilecek bir ayrıntı: CLS'in p75 değeri string olarak dönüyor.

using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json;

namespace Bookstore.Analytics.Crux;

public sealed record CruxRecord(double? LcpP75, double? InpP75, double? ClsP75, DateOnly? From, DateOnly? To);

// Chrome UX Report API: 28 günlük kayan pencere, API key ister (OAuth yok).
public sealed class CruxClient(HttpClient http, string apiKey)
{
    private static readonly string[] Metrics =
        ["largest_contentful_paint", "interaction_to_next_paint", "cumulative_layout_shift"];

    // formFactor: PHONE, DESKTOP ya da TABLET
    public async Task<CruxRecord?> QueryOriginAsync(string origin, string formFactor, CancellationToken ct = default)
    {
        using HttpResponseMessage response = await http.PostAsJsonAsync(
            $"v1/records:queryRecord?key={Uri.EscapeDataString(apiKey)}",
            new { origin, formFactor, metrics = Metrics }, ct);
        if (!response.IsSuccessStatusCode)
        {
            // örneğin CrUX popülerlik eşiğinin altında kalan bir origin
            return null;
        }

        using JsonDocument json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
        JsonElement record = json.RootElement.GetProperty("record");
        JsonElement metrics = record.GetProperty("metrics");
        return new CruxRecord(
            P75(metrics, "largest_contentful_paint"),
            P75(metrics, "interaction_to_next_paint"),
            P75(metrics, "cumulative_layout_shift"),
            Date(record, "firstDate"),
            Date(record, "lastDate"));
    }

    private static double? P75(JsonElement metrics, string name)
    {
        if (!metrics.TryGetProperty(name, out JsonElement metric))
        {
            return null;
        }

        JsonElement p75 = metric.GetProperty("percentiles").GetProperty("p75");
        // CLS string olarak gelir ("0.05"), diğerleri number
        return p75.ValueKind == JsonValueKind.String
            ? double.Parse(p75.GetString()!, CultureInfo.InvariantCulture)
            : p75.GetDouble();
    }

    private static DateOnly? Date(JsonElement record, string name) =>
        record.TryGetProperty("collectionPeriod", out JsonElement period) && period.TryGetProperty(name, out JsonElement d)
            ? new DateOnly(d.GetProperty("year").GetInt32(), d.GetProperty("month").GetInt32(), d.GetProperty("day").GetInt32())
            : null;
}

Dokümanda veri olmayan bir origin için hangi status kodunun döndüğü yazmıyor; client bu yüzden başarısız her yanıtı "veri yok" olarak ele alıyor. API key'i HttpClient'a configuration'dan veriyorsun ve log'lara yazmıyorsun. Testler dokümandaki yanıt şekline göre hazırlanmış, sayıları uydurma bir JSON'la parse'ı, bir de başarısız yanıtı kontrol ediyor.

web-vitals ile Kendi RUM'un

RUM'un Angular tarafını ve yavaşlığın kaynağını bulmayı Web Performansı serisinin "Web Performansını Doğru Ölçmek" yazısında ayrıntılı kurduk; burada SEO açısından önemli kısma, verinin Search Console'un mantığıyla toplanmasına bakıyoruz. bookstore-web'deki service web-vitals'ın onLCP, onINP ve onCLS fonksiyonlarını dinliyor ve sayfa gizlenirken metric'leri tek bir sendBeacon ile gönderiyor:

import { isPlatformBrowser } from '@angular/common';
import { DOCUMENT, Injectable, PLATFORM_ID, inject } from '@angular/core';
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals';

interface RumBeacon {
  id: string;
  name: string;
  value: number;
  pageGroup: string;
}

// Kendi ziyaretçilerimizden field data: çerez yok, kullanıcı ID'si yok; yalnız metric ve route pattern'i.
@Injectable({ providedIn: 'root' })
export class RumService {
  private readonly doc = inject(DOCUMENT);
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  private readonly queue = new Map<string, RumBeacon>();
  private pageGroup = 'other';
  private started = false;

  start(): void {
    if (!this.isBrowser || this.started) {
      return;
    }
    this.started = true;

    // web-vitals aynı metric'i aynı id ile yeniden raporlayabilir: son değeri tut
    const report = (m: Metric) =>
      this.queue.set(m.id, { id: m.id, name: m.name, value: m.value, pageGroup: this.pageGroup });
    onLCP(report);
    onINP(report);
    onCLS(report);

    this.doc.addEventListener('visibilitychange', () => {
      if (this.doc.visibilityState === 'hidden') {
        this.flush();
      }
    });
  }

  // CrUX'teki gibi metric'ler yüklenen sayfaya aittir, sonraki SPA navigasyonlarına değil.
  landedOn(routePattern: string): void {
    if (this.pageGroup === 'other') {
      this.pageGroup = routePattern;
    }
  }

  private flush(): void {
    if (this.queue.size === 0) {
      return;
    }
    const body = new Blob([JSON.stringify([...this.queue.values()])], { type: 'application/json' });
    navigator.sendBeacon('/rum', body);
    this.queue.clear();
  }
}

İki karar Search Console'la uyum için. Birincisi, URL yerine route pattern'i gönderiyoruz (/blog/:slug); Search Console da benzer sayfaları gruplayarak değerlendiriyor ve tek tek URL'lerde p75 hesaplanacak kadar veri birikmez. Route'u bulmak için önceki yazılardaki AnalyticsTitleStrategy'yi kullandık. İkincisi, metric'ler yüklenen sayfaya ait, sonraki SPA (Single Page Application; tek sayfalık uygulama) navigasyonlarına değil; CrUX'in ana metric'leri de hard navigation'ları ölçüyor. web-vitals'ın reportSoftNavs seçeneği Chromium 151 ve sonrasında soft navigation'ları da ölçüyor ve CrUX API'de bunun için experimental_ önekli metric'ler var; deneysel oldukları için şimdilik ayrı tutmak daha doğru. Bu service çerez ve kullanıcı ID'si kullanmıyor, yalnız metric ve route pattern'i gönderiyor; consent'e bağlı olup olmaması yine hukuk ekibinin kararı.

Headless Chromium'da bir yazıyı açıp ziyareti sekme değiştiren bir kullanıcı gibi bitirince, sunucuya giden beacon:

$ node rum-check.mjs http://127.0.0.1:52714
beacon sent: POST /rum application/json
POST /rum [{"id":"v6-1794738273883-8816172319866","name":"LCP","value":28,"pageGroup":"/blog/:slug"},{"id":"v6-1794738273907-2134905930601","name":"CLS","value":0,"pageGroup":"/blog/:slug"}]

Bu değerler tek bir makinedeki tek bir lab koşusundan; field data değil. INP yok, çünkü ziyarette bir tıklama olmadı. Sunucu tarafında beacon'ları toplayan ve Search Console'un kurallarıyla rapora çeviren kod:

// Search Console Core Web Vitals raporunun eşikleri (good "<=" ile).
public static class CoreWebVitals
{
    public static readonly string[] Metrics = ["LCP", "INP", "CLS"];

    public static Rating Rate(string metric, double value)
    {
        (double good, double poor) = metric switch
        {
            "LCP" => (2500d, 4000d),
            "INP" => (200d, 500d),
            "CLS" => (0.1, 0.25),
            _ => throw new ArgumentOutOfRangeException(nameof(metric), metric, "Not a Core Web Vital."),
        };
        return value <= good ? Rating.Good : value <= poor ? Rating.NeedsImprovement : Rating.Poor;
    }
}

public static class Percentile
{
    // Nearest-rank: sample'ların en az p kadarının altında ya da eşit kaldığı en küçük değer.
    public static double NearestRank(IReadOnlyList<double> values, double p)
    {
        if (values.Count == 0)
        {
            throw new ArgumentException("No samples.", nameof(values));
        }

        double[] sorted = [.. values.Order()];
        int rank = (int)Math.Ceiling(p * sorted.Length);
        return sorted[Math.Max(rank, 1) - 1];
    }
}
namespace Bookstore.Analytics.Rum;

public sealed record WebVitalSample(string Id, string Metric, double Value, string PageGroup, string FormFactor);

public sealed record MetricSummary(string Metric, int Samples, double P75, Rating Rating);

public sealed record GroupSummary(string PageGroup, string FormFactor, IReadOnlyList<MetricSummary> Metrics, Rating? Status)
{
    public bool HasEnoughData => Status is not null;
}

public static class RumReport
{
    // Search Console raporundaki gibi: bir grubun yeterli LCP ve CLS verisi olmalı
    // ve durumu en kötü metric'idir.
    public static List<GroupSummary> Build(IEnumerable<WebVitalSample> samples, int minSamples = 50) =>
        samples
            .GroupBy(s => (s.PageGroup, s.FormFactor))
            .OrderBy(g => g.Key.PageGroup, StringComparer.Ordinal).ThenBy(g => g.Key.FormFactor, StringComparer.Ordinal)
            .Select(g =>
            {
                List<MetricSummary> metrics = CoreWebVitals.Metrics
                    .Select(m => g.Where(s => s.Metric == m).Select(s => s.Value).ToList())
                    .Zip(CoreWebVitals.Metrics)
                    .Where(x => x.First.Count > 0)
                    .Select(x =>
                    {
                        double p75 = Percentile.NearestRank(x.First, 0.75);
                        return new MetricSummary(x.Second, x.First.Count, p75, CoreWebVitals.Rate(x.Second, p75));
                    })
                    .ToList();

                bool enough = metrics.Count(m => m.Metric is "LCP" or "CLS" && m.Samples >= minSamples) == 2;
                Rating? status = enough ? metrics.Max(m => m.Rating) : null;
                return new GroupSummary(g.Key.PageGroup, g.Key.FormFactor, metrics, status);
            })
            .ToList();
}
public static class RumEndpoints
{
    // Yalnız bilinen route pattern'leri; serbest URL'ler veriyi p75 hesaplanamayacak kadar küçük gruplara böler.
    private static readonly HashSet<string> PageGroups = new(["/", "/blog/:slug"], StringComparer.Ordinal);

    public static IEndpointRouteBuilder MapRum(this IEndpointRouteBuilder app)
    {
        app.MapPost("/rum", (RumBeacon[] beacons, HttpRequest request, RumStore store) =>
        {
            string formFactor = FormFactor(request.Headers.UserAgent.ToString());
            foreach (RumBeacon b in beacons.Take(10))
            {
                if (IsValid(b))
                {
                    store.Add(new WebVitalSample(b.Id, b.Name, b.Value,
                        PageGroups.Contains(b.PageGroup) ? b.PageGroup : "other", formFactor));
                }
            }

            return Results.NoContent();
        });

Raporu denemek için web/lab/rum-sim.mjs 180 sentetik ziyaretin beacon'larını gönderiyor; değerler uydurma, gerçek ziyaretçi değil. Telefon ve desktop ayrımı User-Agent'tan geliyor:

$ node web/lab/rum-sim.mjs http://127.0.0.1:52706
posted 180 synthetic visits
$ curl -s http://127.0.0.1:52706/rum/report
/ [phone] -> not enough data
    LCP  p75   4665 ms  Poor             n=20
    INP  p75    174 ms  Good             n=10
    CLS  p75      0.02  Good             n=20
/blog/:slug [desktop] -> Good
    LCP  p75   1707 ms  Good             n=80
    INP  p75     86 ms  Good             n=40
    CLS  p75      0.03  Good             n=80
/blog/:slug [phone] -> NeedsImprovement
    LCP  p75   2980 ms  NeedsImprovement n=80
    INP  p75    233 ms  NeedsImprovement n=40
    CLS  p75      0.05  Good             n=80

Okuma Search Console'daki gibi: /blog/:slug telefonda LCP ve INP yüzünden "NeedsImprovement", CLS iyi olsa da grup durumu en kötü metric'i. Desktop'ta aynı sayfalar "Good"; bu yüzden mobil ve desktop'u asla karıştırmıyoruz. Ana sayfanın telefon grubunda LCP p75 kötü görünüyor, ama 20 ziyaret 50'lik eşiğin altında: rapor bir durum vermiyor. Testler eşik sınırlarını (2.500 ms good, 2.501 needs improvement), nearest-rank persentili, en kötü metric kuralını, eksik CLS verisini ve endpoint'in geçersiz beacon'ları atmasını kontrol ediyor:

$ dotnet test --filter "FullyQualifiedName~RumReportTests|FullyQualifiedName~CruxClientTests|FullyQualifiedName~RumEndpointTests"
Passed!  - Failed:     0, Passed:    13, Skipped:     0, Total:    13, Duration: 198 ms - Bookstore.Analytics.Tests.dll (net10.0)

Üretimde Dikkat

Bellekteki store bir demo. Gerçek trafikte beacon'ları bir queue'ya alıp toplu yazmak ya da metric olarak bir observability altyapısına göndermek gerekiyor; OpenTelemetry ve Prometheus ile histogram tutmayı Observability serisinde anlattık.

Cardinality'yi sınırla. Bilinmeyen route'lar other grubuna düşüyor, istek başına en fazla 10 beacon kabul ediliyor ve değerler mantıklı bir aralıkta değilse atılıyor. Endpoint herkese açık olduğu için bot'ların ve sahte beacon'ların veriyi bozabileceğini unutma.

Eşiği trafiğine göre seç. 50 sample bir başlangıç; az trafikli bir sitede haftalık değil aylık pencereyle çalışmak gerekebilir.

RUM'u CrUX'le karşılaştır. İkisi farklı popülasyonları ölçüyor: CrUX yalnız uygun Chrome kullanıcılarını, RUM senin bütün ziyaretçilerini, ama CLS ölçümü yalnız Chromium tarayıcılarda var. Aynı yönde gittiklerini kontrol et, birebir eşleşme bekleme.

Sık Düşülen Tuzaklar

Lighthouse skorunu SEO skoru sanmak. Lab skoru teşhis için; Search Console'daki durum field data'dan geliyor.

Core Web Vitals iyileşmesiyle trafik artışı arasında doğrudan nedensellik kurmak. Google iyi değerlerin sıralama garantisi olmadığını söylüyor; aynı dönemde içerik, mevsim ya da bir core update de değişmiş olabilir. Etkiyi ölçmenin dürüst yolu serinin son yazısında.

Mobil ve desktop'u tek bir sayıda birleştirmek. Search Console ve CrUX ikisini ayrı değerlendiriyor; birleşik bir p75 iki kötü sonucu da gizleyebilir.

Düzeltmeden sonra ertesi gün sonuç beklemek. CrUX 28 günlük bir pencere; Search Console'un doğrulaması da 28 gün izliyor.

Küçük Bir Deneme

★☆☆ PageSpeed Insights'ta kendi sitenin bir sayfasını aç. Üst kısımda field data var mı, yoksa yalnız origin verisi mi? Lighthouse'un LCP'siyle field LCP'sini karşılaştır.

★★☆ RumReport'a bir "yaklaşıyor" uyarısı ekle: "Good" olan ama p75'i eşiğin yüzde 90'ını geçmiş metric'ler işaretlensin. Testini yaz.

★★★ CruxClient'ı ve RumReport'u birleştiren bir komut yaz: aynı origin için CrUX p75'lerini ve RUM p75'lerini yan yana göstersin. Testte CrUX için sahte handler, RUM için sentetik sample kullan.

Ölçmeyi öğrendik; şimdi asıl soru kaldı: bir değişikliğin işe yarayıp yaramadığını nasıl anlarız? Serinin son yazısında önce/sonra analizini, mevsimselliği, kontrol grubunu ve dürüst raporlamayı konuşuyoruz.