İçeriğe geç
·21 dk okuma

2026'da Core Web Vitals: Gerçekten Fark Yaratan Ne?

Teoriyi unut — gerçek bir Next.js üretim sitesinde LCP'yi 2.5s altına, CLS'yi sıfıra ve INP'yi 200ms altına indirmek için gerçekten ne yaptığımı anlatıyorum. Somut teknikler, belirsiz tavsiyeler değil.

Paylaş:X / TwitterLinkedIn

Bu siteyi hızlı yapmak için iki haftanın büyük bölümünü harcadım. "M3 MacBook'umda Lighthouse denetiminde hızlı görünüyor" tarzı hızlı değil. Gerçekten hızlı. Metro tünelinde titrek bir 4G bağlantısında 150 dolarlık bir Android telefonda hızlı. Önemli olan yerde hızlı.

Sonuç: LCP 1.8s altında, CLS 0.00, INP 120ms altında. Üçü de CrUX verisinde yeşil, sadece laboratuvar skorlarında değil. Ve bu süreçte bir şey öğrendim — internetteki performans tavsiyelerinin çoğu ya güncel dışı, ya belirsiz, ya da her ikisi.

"Resimlerini optimize et" tavsiye değil. Bağlam olmadan "lazy loading kullan" tehlikeli. "JavaScript'i küçült" açık ama neyi keseceğin konusunda hiçbir şey söylemiyor.

İşte gerçekten yaptıklarım, önem sırasıyla.

2026'da Core Web Vitals Neden Hala Önemli#

Doğrudan söyleyeyim: Google, Core Web Vitals'ı bir sıralama sinyali olarak kullanıyor. Tek sinyal değil, en önemli olan bile değil. İçerik uygunluğu, backlink'ler ve alan otoritesi hala baskın. Ama sınırlarda — iki sayfanın karşılaştırılabilir içerik ve otoriteye sahip olduğu yerde — performans dengeyi bozuyor. Ve internette, milyonlarca sayfa bu sınırlarda yaşıyor.

Ama bir saniyeliğine SEO'yu unut. Performansı önemsemenin gerçek nedeni kullanıcılar. Veriler son beş yılda pek değişmedi:

  • Bir sayfa 3 saniyeden fazla yüklenirse mobil ziyaretlerin %53'ü terk ediliyor (Google/SOASTA araştırması, hala geçerli)
  • Her 100ms gecikme, dönüşümlerde yaklaşık %1 kayba mal oluyor (Amazon'un orijinal bulgusu, defalarca doğrulandı)
  • Düzen kaymaları yaşayan kullanıcıların bir satın almayı tamamlama veya form doldurma olasılığı önemli ölçüde düşük

2026'da Core Web Vitals üç metrikten oluşuyor:

MetrikNe Ölçerİyiİyileştirme GerekliKötü
LCPYükleme performansı≤ 2.5s2.5s – 4.0s> 4.0s
CLSGörsel kararlılık≤ 0.10.1 – 0.25> 0.25
INPYanıt verebilirlik≤ 200ms200ms – 500ms> 500ms

Bu eşikler, INP'nin Mart 2024'te FID'yi değiştirmesinden bu yana değişmedi. Ama onlara ulaşma teknikleri, özellikle React/Next.js ekosisteminde evrim geçirdi.

LCP: En Çok Önemli Olan#

Largest Contentful Paint, görüntü alanındaki en büyük görünür öğenin render'ını ne zaman tamamladığını ölçer. Çoğu sayfa için bu bir hero resmi, bir başlık veya büyük bir metin bloğudur.

Adım 1: Gerçek LCP Elemanını Bul#

Herhangi bir şeyi optimize etmeden önce, LCP elemanının ne olduğunu bilmen gerekiyor. İnsanlar bunun hero resimleri olduğunu varsayıyor. Bazen <h1>'i render eden bir web fontu oluyor. Bazen CSS ile uygulanan bir arka plan resmi. Bazen bir <video> poster karesi.

Chrome DevTools'u aç, Performance paneline git, bir sayfa yüklemesi kaydet ve "LCP" işaretçisine bak. Hangi öğenin LCP'yi tetiklediğini tam olarak söyler.

Ayrıca web-vitals kütüphanesini kullanarak programatik olarak loglayabilirsin:

tsx
import { onLCP } from "web-vitals";
 
onLCP((metric) => {
  console.log("LCP element:", metric.entries[0]?.element);
  console.log("LCP value:", metric.value, "ms");
});

Bu sitede, LCP elemanının ana sayfadaki hero resmi ve blog yazılarındaki ilk metin paragrafı olduğu ortaya çıktı. İki farklı eleman, iki farklı optimizasyon stratejisi.

Adım 2: LCP Kaynağını Ön Yükle#

LCP elemanın bir resimse, yapabileceğin en etkili tek şey onu ön yüklemektir. Varsayılan olarak, tarayıcı HTML'yi ayrıştırdığında resimleri keşfeder, yani resim isteği HTML indirildikten, ayrıştırıldıktan ve <img> etiketine ulaşıldıktan sonra başlar. Ön yükleme bu keşfi en başa taşır.

Next.js'te layout veya sayfana bir preload link'i ekleyebilirsin:

tsx
import Head from "next/head";
 
export default function HeroSection() {
  return (
    <>
      <Head>
        <link
          rel="preload"
          as="image"
          href="/images/hero-optimized.webp"
          type="image/webp"
          fetchPriority="high"
        />
      </Head>
      <section className="relative h-[600px]">
        <img
          src="/images/hero-optimized.webp"
          alt="Hero banner"
          width={1200}
          height={600}
          fetchPriority="high"
          decoding="sync"
        />
      </section>
    </>
  );
}

fetchPriority="high"'a dikkat et. Bu daha yeni Fetch Priority API'si ve oyun değiştirici. Bu olmadan, tarayıcı kaynakları önceliklendirmek için kendi sezgisellerini kullanır — ve bu sezgiseller, özellikle ekranın üst kısmında birden fazla resmin olduğu durumlarda çoğu zaman yanlış sonuç verir.

Bu sitede, LCP resmine fetchPriority="high" eklemek LCP'yi ~400ms düşürdü. Tek satırlık bir değişiklikten elde ettiğim en büyük kazanç bu.

Adım 3: Render'ı Engelleyen Kaynakları Ortadan Kaldır#

CSS render'ı engeller. Tamamı. <link rel="stylesheet"> ile yüklenen 200KB'lık bir stil sayfan varsa, tarayıcı tamamen indirilip ayrıştırılana kadar hiçbir şey boyamaz.

Çözüm üç yönlü:

  1. Kritik CSS'i inline yap — Ekranın üst kısmı içerik için gereken CSS'i çıkar ve <head>'deki bir <style> etiketine inline yap. Next.js, CSS Modules veya düzgün budama ile Tailwind kullandığında bunu otomatik olarak yapar.

  2. Kritik olmayan CSS'i ertele — Ekranın altı içerik için stil sayfaların varsa (footer animasyon kütüphanesi, grafik bileşeni), asenkron olarak yükle:

html
<link
  rel="preload"
  href="/styles/charts.css"
  as="style"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link rel="stylesheet" href="/styles/charts.css" />
</noscript>
  1. Kullanılmayan CSS'i kaldır — Tailwind CSS v4 bunu JIT motoru ile otomatik olarak yapar. Ama üçüncü taraf CSS kütüphaneleri import ediyorsan, denetle. Tek bir tooltip bileşeni için 180KB CSS import eden bir bileşen kütüphanesi buldum. 20 satırlık özel bir bileşenle değiştirdim ve 170KB tasarruf ettim.

Adım 4: Sunucu Yanıt Süresi (TTFB)#

TTFB yavaşsa LCP hızlı olamaz. Sunucun yanıt vermesi 800ms sürüyorsa, LCP'n en az 800ms + geri kalan her şey olacaktır.

Bu sitede (Node.js + PM2 + Nginx, VPS üzerinde), soğuk vuruşta TTFB'yi yaklaşık 180ms olarak ölçtüm. Orada tutmak için yaptıklarım:

  • Blog yazıları için ISR (Incremental Static Regeneration) — sayfalar build zamanında ön render edilir ve periyodik olarak yeniden doğrulanır. İlk ziyaret, doğrudan Nginx'in reverse proxy önbelleğinden statik dosya sunar.
  • Edge önbellekleme başlıkları — Statik sayfalarda Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400.
  • Nginx'te Gzip/Brotli sıkıştırma — aktarım boyutunu %60-80 azaltır.
bash
# nginx.conf parçası
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
 
# Brotli (ngx_brotli modülü kuruluysa)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;

LCP öncesi/sonrası:

  • Optimizasyon öncesi: 3.8s (75. yüzdelik, CrUX)
  • Preload + fetchPriority + sıkıştırma sonrası: 1.8s
  • Toplam iyileştirme: %53 azalma

CLS: Binlerce Kaymanın Ölümü#

Cumulative Layout Shift, sayfa yüklemesi sırasında görünür içeriğin ne kadar hareket ettiğini ölçer. 0'lık bir CLS hiçbir şeyin kaymadığı anlamına gelir. 0.1'in üzerindeki CLS, bir şeyin kullanıcılarını görsel olarak rahatsız ettiği anlamına gelir.

CLS, çoğu geliştiricinin hafife aldığı metrik. Her şeyin önbelleğe alındığı hızlı geliştirme makinende fark etmezsin. Kullanıcıların telefonlarında, yavaş bağlantılarda, fontların geç yüklendiği ve resimlerin tek tek belirdiği yerde fark eder.

Her Zamanki Suçlular#

1. Açık boyutları olmayan resimler

Bu en yaygın CLS nedeni. Bir resim yüklendiğinde, altındaki içeriği aşağı iter. Çözüm utanç verici derecede basit: <img> etiketlerinde her zaman width ve height belirt.

tsx
// KÖTÜ — düzen kaymasına neden olur
<img src="/photo.jpg" alt="Takım fotoğrafı" />
 
// İYİ — tarayıcı resim yüklenmeden önce yer ayırır
<img src="/photo.jpg" alt="Takım fotoğrafı" width={800} height={450} />

Next.js <Image> kullanıyorsan, boyut sağladığın veya boyutlandırılmış bir üst kapsayıcıyla fill kullandığın sürece bunu otomatik olarak halleder.

Ama bir tuzak var: fill modu kullanıyorsan, üst kapsayıcının açık boyutları olmalı yoksa resim CLS'ye neden olur:

tsx
// KÖTÜ — üst elemanın boyutları yok
<div className="relative">
  <Image src="/photo.jpg" alt="Takım" fill />
</div>
 
// İYİ — üst elemanın açık en-boy oranı var
<div className="relative aspect-video w-full">
  <Image src="/photo.jpg" alt="Takım" fill sizes="100vw" />
</div>

2. FOUT/FOIT'e neden olan web fontları

Özel bir font yüklendiğinde, yedek fontta render edilen metin özel fontta yeniden render edilir. İki fontun farklı metrikleri varsa (neredeyse her zaman vardır), her şey kayar.

Modern çözüm, boyut ayarlı yedek fontlar ile birleştirilmiş font-display: swap:

tsx
// next/font kullanmak — Next.js için en iyi yaklaşım
import { Inter } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  // next/font otomatik olarak boyut ayarlı yedek fontlar üretir
  // Bu, font değişiminden kaynaklanan CLS'yi ortadan kaldırır
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font gerçekten Next.js'teki en iyi şeylerden biri. Fontları build zamanında indirir, self-host eder (çalışma zamanında Google Fonts isteği yok) ve yedekten özel fonta geçişin sıfır düzen kayması oluşturması için boyut ayarlı yedek fontlar üretir. next/font'a geçtikten sonra fontlardan kaynaklanan CLS'yi 0.00 olarak ölçtüm. Öncesinde, standart bir Google Fonts <link> ile 0.04-0.08 arasıydı.

3. Dinamik içerik enjeksiyonu

Reklamlar, çerez banner'ları, bildirim çubukları — ilk render'dan sonra DOM'a enjekte edilen her şey, içeriği aşağı iterse CLS'ye neden olur.

Çözüm: dinamik içerik yüklenmeden önce yer ayır.

tsx
// Çerez banner'ı — altta yer ayır
function CookieBanner() {
  const [accepted, setAccepted] = useState(false);
 
  if (accepted) return null;
 
  return (
    // Fixed konumlandırma CLS'ye neden olmaz çünkü
    // belge akışını etkilemez
    <div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
      <p>Çerez kullanıyoruz. Biliyorsun işte.</p>
      <button onClick={() => setAccepted(true)}>Kabul Et</button>
    </div>
  );
}

Dinamik elemanlar için position: fixed veya position: absolute kullanmak CLS'siz bir yaklaşımdır çünkü bu elemanlar normal belge akışını etkilemez.

4. aspect-ratio CSS hilesi

En-boy oranını bildiğin ama tam boyutları bilmediğin duyarlı kapsayıcılar için CSS aspect-ratio özelliğini kullan:

tsx
// CLS'siz video embed
function VideoEmbed({ src }: { src: string }) {
  return (
    <div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
      <iframe
        src={src}
        className="w-full h-full"
        title="Gömülü video"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media"
        allowFullScreen
      />
    </div>
  );
}

aspect-video yardımcısı (aspect-ratio: 16/9'a karşılık gelir) tam doğru miktarda yer ayırır. iframe yüklendiğinde kayma olmaz.

5. Skeleton ekranlar

Asenkron olarak yüklenen içerik için (API verisi, dinamik bileşenler), beklenen boyutlarla eşleşen bir skeleton göster:

tsx
function PostCardSkeleton() {
  return (
    <div className="animate-pulse rounded-lg border p-4">
      <div className="h-48 w-full rounded bg-gray-200" />
      <div className="mt-4 space-y-2">
        <div className="h-6 w-3/4 rounded bg-gray-200" />
        <div className="h-4 w-full rounded bg-gray-200" />
        <div className="h-4 w-5/6 rounded bg-gray-200" />
      </div>
    </div>
  );
}
 
function PostList() {
  const { data: posts, isLoading } = usePosts();
 
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {Array.from({ length: 6 }).map((_, i) => (
          <PostCardSkeleton key={i} />
        ))}
      </div>
    );
  }
 
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
      {posts?.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Önemli olan, PostCardSkeleton ve PostCard'ın aynı boyutlara sahip olması. Skeleton 200px yüksekliğinde ve gerçek kart 280px yüksekliğindeyse, yine bir kayma yaşarsın.

CLS sonuçlarım:

  • Öncesi: 0.12 (tek başına font değişimi 0.06'ydı)
  • Sonrası: 0.00 — kelimenin tam anlamıyla sıfır, CrUX verisinde binlerce sayfa yüklemesinde

INP: Isıran Yeni Çocuk#

Interaction to Next Paint, Mart 2024'te First Input Delay'in yerini aldı ve temelden daha zor optimize edilen bir metrik. FID sadece ilk etkileşimin işlenmesinden önceki gecikmeyi ölçüyordu. INP, sayfa yaşam döngüsü boyunca her etkileşimi ölçer ve en kötüsünü (75. yüzdelikte) raporlar.

Bu, bir sayfanın harika FID'e sahip olup korkunç INP'ye sahip olabileceği anlamına gelir, mesela yüklemeden 30 saniye sonra bir açılır menüye tıklamak 500ms'lik bir reflow tetiklerse.

Yüksek INP'ye Ne Sebep Olur#

  1. Ana iş parçacığındaki uzun görevler — 50ms'den fazla süren herhangi bir JavaScript çalışması ana iş parçacığını engeller. Uzun bir görev sırasında gerçekleşen kullanıcı etkileşimleri beklemek zorundadır.
  2. React'te pahalı yeniden render'lar — 200 bileşenin yeniden render edilmesine neden olan bir state güncellemesi zaman alır. Kullanıcı bir şeye tıklar, React uzlaştırma yapar ve boyama 300ms boyunca gerçekleşmez.
  3. Layout thrashing — Bir döngüde layout özelliklerini okumak (offsetHeight gibi) sonra yazmak (style.height değiştirmek gibi) tarayıcıyı senkron olarak layout'u yeniden hesaplamaya zorlar.
  4. Büyük DOM — Daha fazla DOM düğümü, daha yavaş stil yeniden hesaplaması ve layout demektir. 5.000 düğümlü bir DOM, 500 düğümlü olandan fark edilir şekilde daha yavaştır.

scheduler.yield() ile Uzun Görevleri Parçalama#

INP için en etkili teknik, tarayıcının parçalar arasında kullanıcı etkileşimlerini işleyebilmesi için uzun görevleri parçalamaktır:

tsx
async function processLargeDataset(items: DataItem[]) {
  const results: ProcessedItem[] = [];
 
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));
 
    // Her 10 öğede bir tarayıcıya yer ver
    // Bu, bekleyen kullanıcı etkileşimlerinin işlenmesini sağlar
    if (i % 10 === 0 && "scheduler" in globalThis) {
      await scheduler.yield();
    }
  }
 
  return results;
}

scheduler.yield() Chrome 129+'da (Eylül 2024) mevcut ve ana iş parçacığına yer vermenin önerilen yolu. Desteklemeyen tarayıcılar için setTimeout(0) sarmalayıcısına geri dönebilirsin:

tsx
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

Acil Olmayan Güncellemeler İçin useTransition#

React 18+, belirli state güncellemelerinin acil olmadığını ve daha önemli işlerle (kullanıcı girdisine yanıt verme gibi) kesilebileceğini React'e söyleyen useTransition'ı verir:

tsx
import { useState, useTransition } from "react";
 
function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
 
    // Bu güncelleme acil — girdi tuş vuruşunu anında yansıtmalı
    setQuery(value);
 
    // Bu güncelleme acil DEĞİL — 10.000 öğeyi filtrelemek bekleyebilir
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  }
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        placeholder="Ara..."
        className="w-full rounded border px-4 py-2"
      />
      {isPending && (
        <p className="mt-2 text-sm text-gray-500">Filtreleniyor...</p>
      )}
      <ul className="mt-4 space-y-2">
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

startTransition olmadan, arama girdisine yazmak hantal hissettirirdi çünkü React, DOM'u güncellemeden önce 10.000 öğeyi senkron olarak filtrelemeye çalışırdı. startTransition ile girdi anında güncellenir ve filtreleme arka planda gerçekleşir.

Karmaşık bir girdi işleyicisine sahip bir araç sayfasında INP ölçtüm. useTransition öncesi: 380ms INP. Sonrası: 90ms INP. Bu, bir React API değişikliğinden %76 iyileştirme.

Girdi İşleyicilerini Debounce Etme#

Pahalı işlemleri (API çağrıları, ağır hesaplamalar) tetikleyen işleyiciler için debounce uygula:

tsx
import { useCallback, useRef } from "react";
 
function useDebounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  return useCallback(
    ((...args: unknown[]) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => fn(...args), delay);
    }) as T,
    [fn, delay]
  );
}
 
// Kullanım
function LiveSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);
 
  const search = useDebounce(async (query: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();
    setResults(data.results);
  }, 300);
 
  return (
    <input
      type="text"
      onChange={(e) => search(e.target.value)}
      placeholder="Ara..."
    />
  );
}

300ms benim varsayılan debounce değerim. Kullanıcıların gecikmeyi fark etmeyeceği kadar kısa, her tuş vuruşunda tetiklemeyi önleyecek kadar uzun.

Ağır Hesaplamalar İçin Web Worker'lar#

Gerçekten ağır hesaplamalar varsa (büyük JSON ayrıştırma, resim manipülasyonu, karmaşık hesaplamalar), ana iş parçacığından tamamen çıkar:

tsx
// worker.ts
self.addEventListener("message", (event) => {
  const { data, operation } = event.data;
 
  switch (operation) {
    case "sort": {
      // Bu, büyük veri setleri için 500ms sürebilir
      const sorted = data.sort((a: number, b: number) => a - b);
      self.postMessage({ result: sorted });
      break;
    }
    case "filter": {
      const filtered = data.filter((item: DataItem) =>
        complexFilterLogic(item)
      );
      self.postMessage({ result: filtered });
      break;
    }
  }
});
tsx
// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
 
function useWorker() {
  const workerRef = useRef<Worker>();
 
  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/worker.ts", import.meta.url)
    );
    return () => workerRef.current?.terminate();
  }, []);
 
  const process = useCallback(
    (operation: string, data: unknown): Promise<unknown> => {
      return new Promise((resolve) => {
        if (!workerRef.current) return;
 
        workerRef.current.onmessage = (event) => {
          resolve(event.data.result);
        };
 
        workerRef.current.postMessage({ operation, data });
      });
    },
    []
  );
 
  return { process };
}

Web Worker'lar ayrı bir iş parçacığında çalışır, bu yüzden 2 saniyelik bir hesaplama bile INP'yi hiç etkilemez. Ana iş parçacığı kullanıcı etkileşimlerini işlemek için serbest kalır.

INP sonuçlarım:

  • Öncesi: 340ms (en kötü etkileşim, karmaşık girdi işlemeli bir regex tester aracıydı)
  • useTransition + debounce sonrası: 110ms
  • İyileştirme: %68 azalma

Next.js'e Özgü Kazanımlar#

Next.js (13+ App Router ile) kullanıyorsan, çoğu geliştiricinin tam olarak kullanmadığı güçlü performans ilkellerine erişimin var.

next/image — Ama Düzgün Yapılandırılmış#

next/image harika, ama varsayılan yapılandırma performansta iyileştirme payı bırakıyor:

tsx
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 365, // 1 yıl
  },
};
 
export default nextConfig;

Önemli ayarlar:

  • formats: ["image/avif", "image/webp"] — AVIF, WebP'den %20-50 daha küçük. Sıra önemli: Next.js önce AVIF'i dener, WebP'ye geri döner, sonra orijinal formata.
  • minimumCacheTTL — Varsayılan 60 saniye. Bir blog için resimler değişmez. Bir yıl boyunca önbelleğe al.
  • deviceSizes ve imageSizes — Varsayılanlar 3840px içerir. 4K resimler sunmuyorsan, bu listeyi kırp. Her boyut ayrı bir önbelleğe alınmış resim üretir ve kullanılmayan boyutlar disk alanı ve build süresi israf eder.

Ve her zaman tarayıcıya resmin hangi boyutta render edileceğini söylemek için sizes prop'unu kullan:

tsx
// Tam genişlik hero resmi
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  sizes="100vw"
  priority // LCP resmi — lazy load etme!
/>
 
// Duyarlı grid'de kart resmi
<Image
  src="/card.jpg"
  alt="Kart"
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

sizes olmadan, tarayıcı 300px'lik bir alan için 1200px'lik bir resim indirebilir. Bu israf edilen byte ve israf edilen zaman.

LCP resmindeki priority prop'u kritik. Lazy loading'i devre dışı bırakır ve otomatik olarak fetchPriority="high" ekler. LCP elemanın bir next/image ise, sadece priority ekle ve işin büyük bölümü tamamdır.

next/font — Sıfır Düzen Kayması Fontları#

Bunu CLS bölümünde ele aldım, ama vurgulamayı hak ediyor. next/font, sürekli olarak sıfır CLS elde eden gördüğüm tek font yükleme çözümü:

tsx
import { Inter, JetBrains_Mono } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});
 
const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="en"
      className={`${inter.variable} ${jetbrainsMono.variable}`}
    >
      <body className="font-sans">{children}</body>
    </html>
  );
}

İki font, sıfır CLS, çalışma zamanında sıfır harici istek. Fontlar build zamanında indirilir ve kendi alan adından sunulur.

Suspense ile Streaming#

Next.js'in performans için gerçekten ilginçleştiği yer burası. App Router ile sayfanın parçalarını hazır oldukça tarayıcıya stream edebilirsin:

tsx
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
 
export default function BlogPage() {
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
      <div className="lg:col-span-2">
        {/* Bu hızlı yüklenir — anında stream et */}
        <h1 className="text-4xl font-bold">Blog</h1>
 
        {/* Bu veritabanı sorgusu gerektirir — hazır olduğunda stream et */}
        <Suspense fallback={<PostListSkeleton />}>
          <PostList />
        </Suspense>
      </div>
 
      <aside>
        {/* Sidebar bağımsız yüklenebilir */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
      </aside>
    </div>
  );
}

Tarayıcı kabuğu (başlık, navigasyon, düzen) anında alır. Yazı listesi ve sidebar verileri hazır oldukça stream edilir. Kullanıcı hızlı bir ilk yükleme görür ve içerik kademeli olarak dolar.

Bu özellikle LCP için güçlü. LCP elemanın başlıksa (yazı listesi değil), veritabanı sorgusunun ne kadar sürdüğünden bağımsız olarak anında render edilir.

Route Segment Yapılandırması#

Next.js, route segment seviyesinde önbellekleme ve yeniden doğrulamayı yapılandırmanı sağlar:

tsx
// app/blog/page.tsx
// Bu sayfayı her saat yeniden doğrula
export const revalidate = 3600;
 
// app/tools/[slug]/page.tsx
// Bu araç sayfaları tamamen statik — build zamanında oluştur
export const dynamic = "force-static";
 
// app/api/search/route.ts
// API route — asla önbelleğe alma
export const dynamic = "force-dynamic";

Bu sitede, blog yazıları revalidate = 3600 (1 saat) kullanıyor. Araç sayfaları force-static kullanıyor çünkü içerikleri deployment'lar arasında değişmiyor. Arama API'si force-dynamic kullanıyor çünkü her istek benzersiz.

Sonuç: çoğu sayfa statik önbellekten sunuluyor, önbelleğe alınmış sayfalar için TTFB 50ms'nin altında ve sunucu neredeyse hiç zorlanmıyor.

Ölçüm Araçları: Gözlerine Değil, Verilere Güven#

Performans algın güvenilmez. Geliştirme makinen 32GB RAM'e, NVMe SSD'ye ve gigabit bağlantıya sahip. Kullanıcılarının yok.

Kullandığım Ölçüm Yığını#

1. Chrome DevTools Performance Paneli

Mevcut en detaylı araç. Bir sayfa yüklemesi kaydet, flamechart'a bak, uzun görevleri tespit et, render engelleyen kaynakları bul. Hata ayıklama zamanımın çoğunu burada geçiriyorum.

Bakılacak önemli şeyler:

  • Görevlerdeki kırmızı köşeler = uzun görevler (>50ms)
  • JavaScript tarafından tetiklenen Layout/Paint olayları
  • Büyük "Evaluate Script" blokları (çok fazla JavaScript)
  • Geç keşfedilen kaynakları gösteren ağ şelalesi

2. Lighthouse

Hızlı bir kontrol için iyi, ama Lighthouse skorları için optimize etme. Lighthouse, gerçek dünya koşullarıyla mükemmel eşleşmeyen simüle edilmiş kısıtlanmış bir ortamda çalışır. Lighthouse'da 98 alan ama sahada 4s LCP'ye sahip sayfalar gördüm.

Lighthouse'u yönlendirici rehberlik için kullan, puan tablosu olarak değil.

3. PageSpeed Insights

Üretim siteleri için en önemli araç çünkü gerçek CrUX verisini gösterir — son 28 gündeki gerçek Chrome kullanıcılarından alınan gerçek ölçümler. Laboratuvar verisi neyin olabileceğini söyler. CrUX verisi neyin olduğunu söyler.

4. web-vitals Kütüphanesi

Gerçek kullanıcı metriklerini toplamak için bunu üretim sitene ekle:

tsx
// components/analytics/WebVitals.tsx
"use client";
 
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
 
function sendToAnalytics(metric: Metric) {
  // Analitik endpoint'ine gönder
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });
 
  // sendBeacon kullan, sayfa kapatmayı engellemesin
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", {
      body,
      method: "POST",
      keepalive: true,
    });
  }
}
 
export function WebVitals() {
  useEffect(() => {
    onCLS(sendToAnalytics);
    onINP(sendToAnalytics);
    onLCP(sendToAnalytics);
    onFCP(sendToAnalytics);
    onTTFB(sendToAnalytics);
  }, []);
 
  return null;
}

Bu sana kendi CrUX benzeri verini verir, ama daha fazla detayla. Sayfaya, cihaz türüne, bağlantı hızına, coğrafi bölgeye göre segmentleyebilirsin — neye ihtiyacın varsa.

5. Chrome User Experience Report (CrUX)

CrUX BigQuery veri seti ücretsiz ve milyonlarca origin için 28 günlük yuvarlanmalı veri içerir. Siten yeterli trafik alıyorsa, kendi verini sorgulayabilirsin:

sql
SELECT
  origin,
  p75_lcp,
  p75_cls,
  p75_inp,
  form_factor
FROM
  `chrome-ux-report.materialized.metrics_summary`
WHERE
  origin = 'https://yoursite.com'
  AND yyyymm = 202603

Üçüncü Taraf Temizliği#

Üçüncü taraf scriptler, çoğu web sitesindeki bir numaralı performans katili. İşte bulduklarım ve yaptıklarım.

Google Tag Manager (GTM)#

GTM'in kendisi ~80KB. Ama GTM başka scriptler yükler — analitik, pazarlama pikselleri, A/B test araçları. Toplam 2MB'lık 15 ek script yükleyen GTM yapılandırmaları gördüm.

Benim yaklaşımım: Üretimde GTM kullanma. Analitik scriptlerini doğrudan yükle, her şeyi ertele ve bekleyebilen scriptler için loading="lazy" kullan:

tsx
// GTM'in her şeyi yüklemesi yerine
// Sadece ihtiyacın olanı, ihtiyacın olduğunda yükle
 
export function AnalyticsScript() {
  return (
    <script
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="your-id"
    />
  );
}

GTM'i kesinlikle kullanman gerekiyorsa, sayfa etkileşimli olduktan sonra yükle:

tsx
"use client";
 
import { useEffect } from "react";
 
export function DeferredGTM({ containerId }: { containerId: string }) {
  useEffect(() => {
    // Sayfa yüklendikten sonra GTM'i enjekte etmek için bekle
    const timer = setTimeout(() => {
      const script = document.createElement("script");
      script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
      script.async = true;
      document.head.appendChild(script);
    }, 3000); // 3 saniye gecikme
 
    return () => clearTimeout(timer);
  }, [containerId]);
 
  return null;
}

Evet, ilk 3 saniyede ayrılan kullanıcılardan veri kaybedersin. Deneyimlerime göre, bu yapılmaya değer bir takas. O kullanıcılar zaten dönüşüm sağlamıyordu.

Sohbet Widget'ları#

Canlı sohbet widget'ları (Intercom, Drift, Crisp) en kötü suçlulardan bazıları. Tek başına Intercom 400KB+'dan fazla JavaScript yükler. Kullanıcıların %2'sinin gerçekten sohbet butonuna tıkladığı bir sayfada, bu %98'lik kullanıcılar için 400KB JavaScript demek.

Çözümüm: Widget'ı etkileşimde yükle.

tsx
"use client";
 
import { useState } from "react";
 
export function ChatButton() {
  const [loaded, setLoaded] = useState(false);
 
  function loadChat() {
    if (loaded) return;
 
    // Sohbet widget scriptini sadece kullanıcı tıkladığında yükle
    const script = document.createElement("script");
    script.src = "https://chat-widget.example.com/widget.js";
    script.onload = () => {
      // Script yüklendikten sonra widget'ı başlat
      window.ChatWidget?.open();
    };
    document.head.appendChild(script);
    setLoaded(true);
  }
 
  return (
    <button
      onClick={loadChat}
      className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
      aria-label="Sohbeti aç"
    >
      {loaded ? "Yükleniyor..." : "Bizimle sohbet et"}
    </button>
  );
}

Kullanılmayan JavaScript#

Chrome DevTools'ta Coverage'ı çalıştır (Ctrl+Shift+P > "Show Coverage"). Her scriptin mevcut sayfada gerçekte ne kadarının kullanıldığını gösterir.

Tipik bir Next.js sitesinde genellikle şunları bulurum:

  • Tamamen yüklenen bileşen kütüphaneleri — Bir UI kütüphanesinden Button import edersin, ama tüm kütüphane paketlenir. Çözüm: tree-shake edilebilir kütüphaneler kullan veya alt yollardan import et (import { Button } from "lib" yerine import Button from "lib/Button").
  • Modern tarayıcılar için polyfill'lerPromise, fetch veya Array.prototype.includes için polyfill gönderip göndermediğini kontrol et. 2026'da bunlara ihtiyacın yok.
  • Ölü özellik bayrakları — Altı aydır "açık" olan özellik bayrakları arkasındaki kod yolları. Bayrağı ve ölü dalı kaldır.

Fazla büyük chunk'ları bulmak için Next.js paket analizcisini kullanıyorum:

tsx
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
 
const nextConfig = {
  // yapılandırman
};
 
export default process.env.ANALYZE === "true"
  ? withBundleAnalyzer({ enabled: true })(nextConfig)
  : nextConfig;
bash
ANALYZE=true npm run build

Bu, paketlerinin görsel bir treemap'ini açar. Yerel Intl.DateTimeFormat ile değiştirdiğim 120KB'lık bir tarih biçimlendirme kütüphanesi buldum. Markdown kullanmayan bir sayfada import edilen 90KB'lık bir markdown ayrıştırıcı buldum. Toplanan küçük kazanımlar.

Render Engelleyen CSS#

Bunu LCP bölümünde bahsettim, ama çok yaygın olduğu için tekrarlamaya değer. <head>'deki her <link rel="stylesheet"> render'ı engeller. Beş stil sayfan varsa, tarayıcı beşini de herhangi bir şey boyamadan önce bekler.

Tailwind ile Next.js bunu iyi halleder — CSS inline'dır ve minimal. Ama üçüncü taraf CSS import ediyorsan, denetle:

tsx
// KÖTÜ — her sayfada tüm kütüphane CSS'ini yükler
import "some-library/dist/styles.css";
 
// DAHA İYİ — dinamik import ile sadece ihtiyaç duyan sayfalarda yüklenir
const SomeComponent = dynamic(
  () => import("some-library").then((mod) => {
    // CSS dinamik bileşenin içinde import ediliyor
    import("some-library/dist/styles.css");
    return mod.SomeComponent;
  }),
  { ssr: false }
);

Gerçek Bir Optimizasyon Hikayesi#

Bu sitenin araçlar sayfasının gerçek optimizasyonunu anlatalım. 15'ten fazla etkileşimli araca sahip bir sayfa, her birinin kendi bileşeni var ve bazıları (regex tester ve JSON formatter gibi) JavaScript-yoğun.

Başlangıç Noktası#

İlk ölçümler (CrUX verisi, mobil, 75. yüzdelik):

  • LCP: 3.8s — Kötü
  • CLS: 0.12 — İyileştirme Gerekli
  • INP: 340ms — Kötü

Lighthouse skoru: 62.

Araştırma#

LCP analizi: LCP elemanı sayfa başlığıydı (<h1>), anında render olması gerekirdi. Ama şunlar tarafından geciktirildi:

  1. Bir bileşen kütüphanesinden 200KB CSS dosyası (render engelleyen)
  2. Google Fonts CDN'den yüklenen özel font (yavaş bağlantılarda 800ms FOIT)
  3. Sayfa her istekte önbelleksiz sunucu tarafında render edildiği için 420ms TTFB

CLS analizi: Üç kaynak:

  1. Google Fonts yedek fonttan özel fonta geçiş: 0.06
  2. Yükseklik ayrılmadan yüklenen araç kartları: 0.04
  3. Sayfanın üstüne enjekte edilen, her şeyi aşağı iten çerez banner'ı: 0.02

INP analizi: Regex tester aracı en kötü suçluydu. Regex girdisindeki her tuş vuruşu şunları tetikliyordu:

  1. Tüm araç bileşeninin tam yeniden render'ı
  2. Test stringi üzerinde regex değerlendirmesi
  3. Regex kalıbının sözdizimi renklendirmesi

Tuş vuruşu başına toplam süre: 280-400ms.

Düzeltmeler#

1. Hafta: LCP ve CLS

  1. Google Fonts CDN'yi next/font ile değiştirdim. Font artık self-host, build zamanında yükleniyor, boyut ayarlı yedekle. Fontlardan CLS: 0.06 → 0.00

  2. Bileşen kütüphanesi CSS'ini kaldırdım. Kullandığım 3 bileşeni Tailwind ile yeniden yazdım. Kaldırılan toplam CSS: 180KB. Render engelleyen CSS: ortadan kaldırıldı

  3. Araçlar sayfasına ve araç detay sayfalarına revalidate = 3600 ekledim. İlk vuruş sunucu tarafında render ediliyor, sonraki vuruşlar önbellekten sunuluyor. TTFB: 420ms → 45ms (önbellekli)

  4. Tüm araç kartı bileşenlerine açık boyutlar ekledim ve duyarlı düzenler için aspect-ratio kullandım. Kartlardan CLS: 0.04 → 0.00

  5. Çerez banner'ını ekranın altına position: fixed olarak taşıdım. Banner'dan CLS: 0.02 → 0.00

2. Hafta: INP

  1. Regex tester'ın sonuç hesaplamasını startTransition ile sardım:
tsx
function RegexTester() {
  const [pattern, setPattern] = useState("");
  const [testString, setTestString] = useState("");
  const [results, setResults] = useState<RegexResult[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handlePatternChange(value: string) {
    setPattern(value); // Acil: girdiyi güncelle
 
    startTransition(() => {
      // Acil değil: eşleşmeleri hesapla
      try {
        const regex = new RegExp(value, "g");
        const matches = [...testString.matchAll(regex)];
        setResults(
          matches.map((m) => ({
            match: m[0],
            index: m.index ?? 0,
            groups: m.groups,
          }))
        );
      } catch {
        setResults([]);
      }
    });
  }
 
  return (
    <div>
      <input
        value={pattern}
        onChange={(e) => handlePatternChange(e.target.value)}
        className={isPending ? "opacity-70" : ""}
      />
      {/* sonuçlar render'ı */}
    </div>
  );
}

Regex tester'da INP: 380ms → 85ms

  1. JSON formatter'ın girdi işleyicisine debounce ekledim (300ms gecikme). JSON formatter'da INP: 260ms → 60ms

  2. Hash generator'ın hesaplamasını bir Web Worker'a taşıdım. Büyük girdilerin SHA-256 hash'lemesi artık tamamen ana iş parçacığı dışında gerçekleşiyor. Hash generator'da INP: 200ms → 40ms

Sonuçlar#

İki haftalık optimizasyondan sonra (CrUX verisi, mobil, 75. yüzdelik):

  • LCP: 3.8s → 1.8s (%53 iyileştirme)
  • CLS: 0.12 → 0.00 (%100 iyileştirme)
  • INP: 340ms → 110ms (%68 iyileştirme)

Lighthouse skoru: 62 → 97.

Üç metrik de rahatça "İyi" aralığında. Sayfa mobilde anında hissediliyor. Ve organik arama trafiği iyileştirmeleri takip eden ayda %12 arttı (nedenselliği kanıtlayamam — başka faktörler de etkili).

Kontrol Listesi#

Bu yazıdan başka hiçbir şey almazsan, işte her projede uyguladığım kontrol listesi:

LCP#

  • DevTools ile LCP elemanını tespit et
  • LCP resmine priority (veya fetchPriority="high") ekle
  • LCP kaynaklarını <head>'de ön yükle
  • Render engelleyen CSS'i ortadan kaldır
  • next/font ile fontları self-host et
  • Brotli/Gzip sıkıştırmayı etkinleştir
  • Mümkün olduğunca statik oluşturma veya ISR kullan
  • Statik varlıklar için agresif cache başlıkları ayarla

CLS#

  • Tüm resimlerin açık width ve height'ı var
  • Boyut ayarlı yedeklerle next/font kullanılıyor
  • Dinamik içerik position: fixed/absolute veya ayrılmış alan kullanıyor
  • Skeleton ekranlar gerçek bileşen boyutlarıyla eşleşiyor
  • Yüklemeden sonra sayfa üstü içerik enjeksiyonu yok

INP#

  • Etkileşim işleyicileri sırasında uzun görev (>50ms) yok
  • Acil olmayan state güncellemeleri startTransition ile sarılmış
  • Girdi işleyicileri debounce edilmiş (300ms)
  • Ağır hesaplamalar Web Worker'lara aktarılmış
  • Mümkün olduğunca DOM boyutu 1.500 düğümün altında

Genel#

  • Üçüncü taraf scriptler sayfa etkileşimli olduktan sonra yükleniyor
  • Paket boyutu analiz edilmiş ve tree-shake edilmiş
  • Kullanılmayan CSS kaldırılmış
  • Resimler AVIF/WebP formatında sunuluyor
  • Üretimde gerçek kullanıcı izleme (web-vitals kütüphanesi)

Son Düşünceler#

Performans optimizasyonu tek seferlik bir görev değil. Bir disiplin. Her yeni özellik, her yeni bağımlılık, her yeni üçüncü taraf script potansiyel bir gerileme. Hızlı kalan siteler, birinin metrikleri sürekli izlediği siteler, bir defalık optimizasyon sprinti yapılan siteler değil.

Gerçek kullanıcı izleme kur. Metrikler gerilediğinde uyarılar kur. Performansı kod inceleme sürecinin bir parçası yap. Birisi 200KB'lık bir kütüphane eklediğinde, 5KB'lık bir alternatif olup olmadığını sor. Birisi bir olay işleyicisine senkron hesaplama eklediğinde, ertelenip ertelenmeyeceğini veya bir worker'a taşınıp taşınamayacağını sor.

Bu yazıdaki teknikler teorik değil. Gerçekten yaptığım şeyler, bu sitede, desteklemek için gerçek rakamlarla. Senin sonuçların farklı olacak — her site farklı, her kitle farklı, her altyapı farklı. Ama prensipler evrensel: daha az yükle, daha akıllı yükle, ana iş parçacığını engelleme.

Kullanıcıların hızlı bir site için teşekkür notu göndermeyecek. Ama kalacaklar. Geri gelecekler. Ve Google fark edecek.

İlgili Yazılar