teknikbakis

Fontları kendi sunucuma aldım: kayma milim oynamadı, LCP 1,27 saniyeden 3,4 saniyeye çıktı

Mobildeki 0,187'lik düzen kaymasının suçlusu CDN'den geç gelen fontlar sanılıyordu. Fontları öne çekmek kaymayı azaltmadı; sayfanın en önemli görselinin bant genişliğini çaldı. Asıl mesele fontun ne zaman geldiği değil, geldiğinde neyi değiştirdiğiydi.

DOSYA TB-2026-014
SİSTEM Tanıtım sayfası · @font-face · Chrome
KATMAN Web performansı / font yükleme
SÜRE 1 deneme · aynı gün geri alındı
DURUM AÇIK
KANIT 3 ek

Semptom

2 Ekim 2026'da kendi projem Siber Tuzak'ın tanıtım sayfasının hızıyla uğraşıyordum. Aynı gün LCP'yi bir kez düzeltmiştim; mobil Slow 4G profilinde canlı ölçüm 1,95 saniyeye inmişti. Geriye tek kırmızı sayı kalmıştı: mobil CLS 0,187. "İyi" sayılan eşik 0,1. Hızlı 4G'de aynı sayfa 0,098 veriyordu, yani eşiğin hemen altında; sorun yavaş bağlantıdaydı.

Sayfanın iki fontu vardı: başlıkta Archivo, giriş paragrafında Source Serif. İkisi de @fontsource paketlerinden jsDelivr üzerinden geliyordu. Yavaş ağda fontlar dördüncü saniye civarında iniyor, o anda başlık ve giriş paragrafı yerinden oynuyordu. Teşhis kendiliğinden yazılmış gibiydi: font geç geliyor, öyleyse fontu erken getir.

Yanlış yollar

1. Fontu öne çekmek

Planım üç hamleydi ve üçü de aynı şeyi söylüyordu: fontu daha erken iste. Font dosyalarını kendi sunucuma aldım, @font-face tanımlarını harici CSS yerine sayfanın içindeki <style> bloğuna koydum, başlık fontu için de preload ekledim:

<link rel="preload" href="/statik/font/archivo.woff2"
      as="font" type="font/woff2" crossorigin>
<style>
  @font-face {
    font-family: "Archivo";
    src: url("/statik/font/archivo.woff2") format("woff2");
    font-display: swap;
  }
  /* Source Serif için de aynısı */
</style>

Sonuç iki yönden kötüydü. Kayma hiç değişmedi; üstüne LCP neredeyse üç katına çıktı:

EK-1 Aynı koşulda eski ve yeni HTML
Koşul: mobil öykünme · Slow 4G · belge isteği yakalanmış

                               LCP       CLS
eski HTML (CDN'den font)       1,27 sn   0,187
yeni HTML (kendi sunucu)       3,4 sn    değişmedi

Değişiklik geri alındı. Font klasörü sunucudan silindi, sayfa eski hâline döndü.

2. font-display: optional

İkinci deneme, swap'ı tamamen ortadan kaldırmaktı. optional ile tarayıcı fontu çok kısa bir süre bekler; yetişmezse o sayfa görünümünde yedek fontla kalır, fontu yalnızca sonraki gezinme için önbelleğe alır. Swap olmayınca kayma da olmaz: CLS 0'a indi.

Sayı mükemmeldi, sayfa değildi. Hangi fontun gerçekten çizildiğine baktığımda, ilk girişte masaüstünde bile gerçek fontun hiç kullanılmadığını gördüm; başlık Liberation Sans, paragraf Liberation Serif ile çiziliyordu. Tanıtım sayfasının ilk izlenimi tasarımın kendisi. Metriği sıfırlayıp tasarımı silen bir çözümü reddettim.

Doğru test

Bu vakada iki ölçüm alışkanlığı kararı belirledi. Birincisi kıyası aynı düzenekte yapmak. Elimde aynı günden bir canlı ölçüm vardı: Slow 4G'de 1,95 sn. İstek yakalamalı düzenekte eski HTML ise 1,27 sn verdi. Bu iki sayı yan yana konamaz: biri gerçek sunucunun yanıt süresini (TTFB) içeriyor, diğerinde belge yanıtını test betiği veriyor. Yeni sürümü düzenekte ölçüp canlıdaki 1,95 ile kıyaslasaydım, yöntem farkını iyileşme sanabilirdim.

EK-2 Düzeneğin iskeleti (sadeleştirilmiş)
const html = fs.readFileSync(SURUM, 'utf8');   // eski.html ya da yeni.html

await page.emulateNetworkConditions(PredefinedNetworkConditions['Slow 4G']);
await page.setRequestInterception(true);
page.on('request', req => {
  if (req.isNavigationRequest() && req.url() === HEDEF) {
    req.respond({ status: 200, contentType: 'text/html; charset=utf-8', body: html });
  } else {
    req.continue();   // görsel, font, CSS gerçek sunuculardan
  }
});
await page.goto(HEDEF, { waitUntil: 'networkidle0' });

Belge iki sürümde de aynı yoldan ve aynı hızla geliyor; fark yalnızca HTML'in içeriği. Ağ öykünmesi, cihaz profili ve önbellek durumu iki koşuda birebir aynı tutulmalı.

İkincisi hangi fontun çizildiğini doğrulamak. CSS'te yazan font ailesi, ekranda kullanılan font değildir; tarayıcı o anda elinde ne varsa onu çizer. Chrome DevTools Protokolü bunu düğüm başına söylüyor:

EK-3 Gerçekte çizilen font
const cdp = await page.createCDPSession();
await cdp.send('DOM.enable');
await cdp.send('CSS.enable');
const { root } = await cdp.send('DOM.getDocument');
const { nodeId } = await cdp.send('DOM.querySelector',
  { nodeId: root.nodeId, selector: 'h1' });
const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId });

// font-display: optional, ilk giriş, masaüstü:
// h1    → Liberation Sans    (beklenen: Archivo)
// lede  → Liberation Serif   (beklenen: Source Serif)

CLS 0 diyen ölçüm doğruydu; eksik olan, o sıfırın neyle satın alındığını gösteren bu satırdı.

Kök neden

Ortada iki ayrı neden vardı ve ben ikisini tek bir hikâyeye bağlamıştım.

LCP neden bozuldu

CDN düzeninde tarayıcı fontlara giden yolu adım adım keşfediyordu: önce HTML, sonra başka bir alan adındaki CSS (yeni bağlantı, TLS el sıkışması, CSS'in inmesi), ancak ondan sonra font dosyaları. Bu zincir fontları kendiliğinden geriye itiyordu. Bu sırada sayfanın LCP öğesi olan, fetchpriority="high" taşıyan amblem görseli hattı neredeyse tek başına kullanıyordu.

Kendi sunucu düzeninde bu zincir kalktı. preload başlık fontunu daha HTML ayrıştırılırken istetti; satır içi @font-face ise öbür fontu da ilk stil hesabında istetti. Yaklaşık 330 KB font, aynı sunucudan, aynı bağlantı üzerinden, aynı anda LCP görseliyle yarışmaya başladı. Yavaş bir hatta bant genişliği paylaşılınca görsel geç bitti.

ÇIKARIM

CDN'in gecikmesi bir kusur gibi görünüyordu; aslında kimsenin tasarlamadığı bir önceliklendirmeydi. Onu kaldırınca sayfa, en önemli görselinin hattını fontlarla paylaşmak zorunda kaldı.

CLS neden değişmedi

Kaymayı öğelere ayırınca iki kaynak çıktı: başlık (Archivo) 0,098 ve giriş paragrafı (Source Serif) 0,084. İkisinin de mekanizması aynı. Sayfa önce yedek fontla çiziliyor; web fontu gelince swap oluyor. Web fontunun harf genişlikleri ve satır yüksekliği yedek fonttan farklı olduğu için satır kırılımı değişiyor, blok uzayıp kısalıyor ve altındaki her şey yer değiştiriyor.

Bu kaymanın büyüklüğünü belirleyen, fontun ne zaman geldiği değil, iki font arasındaki ölçü farkı. Fontu dördüncü saniye yerine ikinci saniyede getirsem de swap ilk boyamadan sonra olduğu sürece kayma aynı kalır. Kaymanın kaybolması için fontun ilk boyamaya yetişmesi gerekir; yavaş bir hatta 330 KB için bu gerçekçi değil.

ÇIKARIM

Fontu hızlandırmak swap kaymasını yok etmez, yalnızca zamanını kaydırır. Kaymayı yok eden şey, swap anında hiçbir şeyin ölçüsünün değişmemesidir.

Çözüm

Değişiklik aynı gün geri alındı; LCP eski değerine döndü. CLS sorunu ise hâlâ açık. Kalan yol, yedek fontu web fontunun ölçülerine uydurmak: swap olduğunda satırlar aynı yerden kırılırsa kayacak bir şey kalmaz.

/* Değerler örnektir; gerçek oranlar iki fontun metriklerinden hesaplanır. */
@font-face {
  font-family: "Archivo Yedek";
  src: local("Arial"), local("Liberation Sans");
  size-adjust: 106%;
  ascent-override: 88%;
  descent-override: 21%;
  line-gap-override: 0%;
}
h1 { font-family: "Archivo", "Archivo Yedek", sans-serif; }

Arial ile Liberation Sans aynı metriklerle tasarlandığı için bir oran takımı ikisinde de geçerli. Paragraf için aynı iş Source Serif'e karşı bir serif yedekle yapılacak.

Dosyanın neden açık kaldığına gelince: başlıkta Archivo, font-stretch: 120% ile geniş kesimde kullanılıyor. size-adjust harfleri her yönde aynı oranda büyütür. Geniş bir kesimin satır genişliğini tutturmak için yedeği büyütürsem yükseklik de büyür. Satır kırılımını ve satır yüksekliğini aynı anda eşlemek bu yüzden ölçüp ayarlanacak bir uzlaşma işi, tek satırlık bir düzeltme değil.

Self-host yanlış değil, bu site de öyle

Bu yazı "fontları kendi sunucunda tutma" demiyor. Okuduğunuz site, teknikbakis.com, bütün fontlarını kendi sunucusundan veriyor ve bunu bilerek yapıyor: CDN'den gelen her font isteği ziyaretçinin IP adresini ve hangi siteden geldiğini üçüncü bir tarafa taşır. Üstelik tarayıcılar önbelleği artık siteye göre bölüyor; "herkes o fontu zaten CDN'den indirmiştir" avantajı da geçmişte kaldı. Gizlilik tarafında karar net.

Bu sitedeki düzen şöyle: fontlar sayfa içinde değil, ayrı bir stil dosyasında tanımlı; font-display: swap kullanılıyor, preload yok ve dosyalar unicode-range ile latin ve latin-ext alt kümelerine bölünmüş. Kayıt sayfalarında görsel olmadığı için fontun bir LCP görseliyle yarışması burada söz konusu değil. Ama yedek font metrik ayarı bu sitede de yok. Aynı ölçümden geçirmeden "burada sorun yok" demek, bu yazının anlattığı hatanın ta kendisi olur.

Self-host yaparken artık şu listeye bakıyorum:

  • Önce kaymayı öğeye ayır. Kayma swap'tan mı, boyutu belirsiz bir görselden mi, sonradan eklenen bir bloktan mı? Neden bilinmeden yapılan font değişikliği tahmindir.
  • Preload'u LCP'ye göre ver. LCP öğesi bir başlıksa, o başlığın fontu için tek bir preload anlamlı olabilir. LCP bir görselse font preload'u o görselin hattını paylaşır; tek tek ölçmeden ekleme.
  • Alt kümele. unicode-range ile latin ve latin-ext'i ayır, kullanmadığın ağırlık ve italikleri yükleme. Türkçe sayfada ğ, ş ve İ latin-ext alt kümesinde olduğu için iki dosya da iner; bütçeyi buna göre hesapla.
  • Yedek fontu metrikle eşle. size-adjust, ascent-override, descent-override: swap kaymasını gerçekten azaltan katman bu.
  • Kararı aynı düzenekte A/B ile ver, çizilen fontu CSS.getPlatformFontsForNode ile doğrula.

Kalıcı ders

  • Doğru gözlem, doğru neden değildir. "Font geç geliyor" doğruydu. "Kayma fontun geç gelmesinden" yanlıştı. İki sayı (öğe başına kayma) bu ayrımı değişiklikten önce gösterebilirdi.
  • Bir şeyi öne almak başka bir şeyi geriye iter. Kaynaklar aynı hattı paylaşıyorsa, hızlandırdığın şeyin kimin önüne geçtiğini ölç.
  • Metriği sıfırlayan çözüm, metriğin koruduğu şeyi silebilir. optional CLS'yi 0 yaptı ve fontu sayfadan kaldırdı. Sayıyla birlikte sonucu da doğrula.
  • Ölçümler yalnızca aynı düzenekte kıyaslanır. Canlı ölçüm ile istek yakalamalı ölçüm farklı TTFB taşır; ikisini yan yana koymak yöntem farkını sonuç sanmaktır.