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.
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:
| Metrik | Ne Ölçer | İyi | İyileştirme Gerekli | Kötü |
|---|---|---|---|---|
| LCP | Yükleme performansı | ≤ 2.5s | 2.5s – 4.0s | > 4.0s |
| CLS | Görsel kararlılık | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | Yanıt verebilirlik | ≤ 200ms | 200ms – 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:
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:
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ü:
-
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. -
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:
<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>- 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.
# 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.
// 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:
// 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:
// 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.
// Ç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:
// 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:
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#
- 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.
- 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.
- Layout thrashing — Bir döngüde layout özelliklerini okumak (
offsetHeightgibi) sonra yazmak (style.heightdeğiştirmek gibi) tarayıcıyı senkron olarak layout'u yeniden hesaplamaya zorlar. - 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:
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:
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:
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:
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:
// 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;
}
}
});// 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:
// 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.deviceSizesveimageSizes— 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:
// 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ü:
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:
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:
// 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:
// 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:
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:
// 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:
"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.
"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
Buttonimport 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"yerineimport Button from "lib/Button"). - Modern tarayıcılar için polyfill'ler —
Promise,fetchveyaArray.prototype.includesiç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:
// 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;ANALYZE=true npm run buildBu, 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:
// 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:
- Bir bileşen kütüphanesinden 200KB CSS dosyası (render engelleyen)
- Google Fonts CDN'den yüklenen özel font (yavaş bağlantılarda 800ms FOIT)
- Sayfa her istekte önbelleksiz sunucu tarafında render edildiği için 420ms TTFB
CLS analizi: Üç kaynak:
- Google Fonts yedek fonttan özel fonta geçiş: 0.06
- Yükseklik ayrılmadan yüklenen araç kartları: 0.04
- 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:
- Tüm araç bileşeninin tam yeniden render'ı
- Test stringi üzerinde regex değerlendirmesi
- 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
-
Google Fonts CDN'yi
next/fontile değiştirdim. Font artık self-host, build zamanında yükleniyor, boyut ayarlı yedekle. Fontlardan CLS: 0.06 → 0.00 -
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ı
-
Araçlar sayfasına ve araç detay sayfalarına
revalidate = 3600ekledim. İlk vuruş sunucu tarafında render ediliyor, sonraki vuruşlar önbellekten sunuluyor. TTFB: 420ms → 45ms (önbellekli) -
Tüm araç kartı bileşenlerine açık boyutlar ekledim ve duyarlı düzenler için
aspect-ratiokullandım. Kartlardan CLS: 0.04 → 0.00 -
Çerez banner'ını ekranın altına
position: fixedolarak taşıdım. Banner'dan CLS: 0.02 → 0.00
2. Hafta: INP
- Regex tester'ın sonuç hesaplamasını
startTransitionile sardım:
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
-
JSON formatter'ın girdi işleyicisine debounce ekledim (300ms gecikme). JSON formatter'da INP: 260ms → 60ms
-
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(veyafetchPriority="high") ekle - LCP kaynaklarını
<head>'de ön yükle - Render engelleyen CSS'i ortadan kaldır
-
next/fontile 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
widthveheight'ı var - Boyut ayarlı yedeklerle
next/fontkullanılıyor - Dinamik içerik
position: fixed/absoluteveya 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
startTransitionile 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.