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

Görsel ve Font Optimizasyonu: AVIF/WebP, srcset, NgOptimizedImage Loader'ları ve Türkçe Karakterli Font Subset'leri

Mert Özen 15 Kas 2026 13 dk 5 görüntülenme
Görsel ve Font Optimizasyonu: AVIF/WebP, srcset, NgOptimizedImage Loader'ları ve Türkçe Karakterli Font Subset'leri

Görseli ekrandaki boyuta göre küçültüp tarayıcının kabul ettiği en iyi formatta (AVIF, WebP, JPEG) sunmak ve font'u doğru subset ve fallback metrikleriyle yüklemek, LCP ve CLS'i en çok etkileyen iki iş. Bookstore kapaklarını ASP.NET Core'da Magick.NET ile Accept'e göre kodlayıp NgOptimizedImage loader'ıyla srcset üretiyor, LCP'yi 13,7 sn'den 1,34 sn'ye indiriyoruz; latin subset'inde ğ, ş, İ'nin fallback font'a düştüğünü ve size-adjust'ın kaymayı nasıl azalttığını ölçüyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (Angular 22.2'nin desteklediği TypeScript 6.0 ile), .NET 10 (ASP.NET Core 10), Magick.NET 15.1 (Magick.NET-Q8-AnyCPU), fontTools 4.60, Inter 4.1 (inter-ui paketi) ve Playwright 1.65 ile denenmiştir. Bütün sayılar tek bir makinede, yerel build üzerinde, headless Chromium 158 ile alınmış lab ölçümleridir. Tarayıcı destek bilgileri MDN'in (Mozilla'nın web dokümantasyonu) browser-compat-data 8.1.9 sürümünden (2026-11-12).

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) ana sayfası, önceki yazının sonunda telefonda şu durumdaydı:

$ node measure.mjs http://127.0.0.1:5300/ --runs 5 --profile mobile
run     TTFB    FCP    LCP    CLS
med      151    308  13692      0
LCP subparts (median): ttfb=151 loadDelay=27 loadDuration=13510 renderDelay=17
LCP element: html>body>app-root>main>app-home-page>section.hero>img.hero-cover

LCP (Largest Contentful Paint; viewport'taki en büyük içeriğin çizildiği an) 13,7 sn ve bunun 13,5 sn'si load duration, yani kapak görselinin inmesi. Dune'un kapağı 1200×1800 piksellik, 440 KB'lık bir JPEG (en yaygın kayıplı fotoğraf formatı); telefonda 380 CSS piksel genişliğinde gösteriliyor ve sayfada sekiz kapak daha var. Okumaya devam etmeden önce tahmin et: aynı kapak 480 piksel genişliğe küçültülüp AVIF (AV1 video codec'ine dayanan görsel formatı) olarak sunulursa kaç KB olur? 100 KB mı, 30 KB mı, 5 KB mı?

Format Seçimi: AVIF, WebP, JPEG ve JPEG XL

Fotoğraf benzeri görseller için bugün üç format yaygın: JPEG (her yerde çalışır), WebP (JPEG'e göre genelde belirgin şekilde küçük) ve AVIF (AV1 video codec'inin görsel formatı; çoğu zaman en küçüğü ama kodlaması en pahalısı). browser-compat-data'ya göre AVIF Chrome 85, Edge 121, Firefox 93 ve Safari 16.4'ten beri destekleniyor; WebP her güncel tarayıcıda var. JPEG XL'in (JPEG'in yerine tasarlanmış yeni nesil format) durumu 2026'da değişti: Chrome 155 ve Firefox 158'de destekleniyor, Safari 17'den beri var ama Edge'de hâlâ yok. Bu yüzden JPEG XL'i ancak Accept header'ına bakarak, desteklemeyen tarayıcıya başka format vererek sunabilirsin; bu yazıda AVIF, WebP ve JPEG ile sınırlı kalıyoruz.

Kalite ayarı formatlar arasında aynı anlama gelmiyor: JPEG ve WebP için 75, AVIF için 50 kabaca benzer görsel kalite veriyor ve bu yazıdaki değerler bunlar. Kendi görsellerinde kaliteyi gözle karşılaştırarak seç; kapak gibi düz renkli, metinli görsellerle fotoğraflar farklı davranır.

ASP.NET Core'da Boyutlandırma ve Accept'e Göre Format

Orijinal kapaklar API'nin Covers/ klasöründe duruyor. Yeni bir endpoint, istenen genişliğe en yakın izinli genişliği seçiyor, tarayıcının Accept header'ına göre formatı belirliyor ve sonucu bellekte cache'liyor. Görsel işleme için Magick.NET (ImageMagick'in .NET paketi, Apache 2.0 lisanslı) kullandık; AVIF ve WebP yazabiliyor:

dotnet add package Magick.NET-Q8-AnyCPU --version 15.1.0
using ImageMagick;
using Microsoft.Extensions.Caching.Memory;

namespace Bookstore.Api.Images;

public sealed record EncodedImage(byte[] Bytes, string ContentType);

// Orijinal kapağı (Covers/*.jpg) küçültüp AVIF, WebP ya da JPEG olarak kodlar.
// Sonuçlar boyutu sınırlı bir bellek cache'inde tutulur; üretimde önünde bir CDN ya da disk cache'i durur.
public sealed class CoverImageService(IWebHostEnvironment env, IMemoryCache cache)
{
    // Yalnız bu genişlikler üretilir: rastgele bir ?w= herkesin cache'i doldurmasına ve CPU yakmasına izin verirdi
    public static readonly int[] Widths = [160, 320, 480, 640, 960, 1200];

    public async Task<EncodedImage?> GetAsync(string name, int width, string format, CancellationToken ct)
    {
        string path = Path.Combine(env.ContentRootPath, "Covers", name + ".jpg");
        if (!File.Exists(path))
        {
            return null;
        }
        string key = $"cover:{name}:{width}:{format}";
        return await cache.GetOrCreateAsync(key, async entry =>
        {
            EncodedImage image = await EncodeAsync(path, width, format, ct);
            entry.Size = image.Bytes.Length;
            return image;
        });
    }

    private static async Task<EncodedImage> EncodeAsync(string path, int width, string format, CancellationToken ct)
    {
        using MagickImage image = new(await File.ReadAllBytesAsync(path, ct));
        image.Resize((uint)width, 0);
        image.Strip();
        (MagickFormat magickFormat, uint quality, string contentType) = format switch
        {
            "avif" => (MagickFormat.Avif, 50u, "image/avif"),
            "webp" => (MagickFormat.WebP, 75u, "image/webp"),
            _ => (MagickFormat.Jpeg, 75u, "image/jpeg"),
        };
        image.Quality = quality;
        return new EncodedImage(image.ToByteArray(magickFormat), contentType);
    }
}
using System.Text.RegularExpressions;

namespace Bookstore.Api.Images;

public static partial class CoverImageEndpoints
{
    public static IEndpointRouteBuilder MapCoverImageEndpoints(this IEndpointRouteBuilder app)
    {
        // GET /img/covers/dune?w=480 -> w'ye eşit ya da büyük en küçük izinli genişlik, tarayıcının kabul ettiği en iyi formatta
        app.MapGet("/img/covers/{name}", async Task<IResult> (string name, int? w, HttpContext http, CoverImageService images, CancellationToken ct) =>
        {
            if (!SafeName().IsMatch(name))
            {
                return TypedResults.NotFound();
            }
            int width = CoverImageService.Widths.FirstOrDefault(x => x >= (w ?? 1200), CoverImageService.Widths[^1]);
            string accept = http.Request.Headers.Accept.ToString();
            string format = accept.Contains("image/avif") ? "avif" : accept.Contains("image/webp") ? "webp" : "jpeg";

            EncodedImage? image = await images.GetAsync(name, width, format, ct);
            if (image is null)
            {
                return TypedResults.NotFound();
            }
            // Aynı URL, Accept header'ına göre farklı byte'lar: cache'ler bunu anahtara katmalı
            http.Response.Headers.Vary = "Accept";
            http.Response.Headers.CacheControl = "public, max-age=86400";
            return TypedResults.Bytes(image.Bytes, image.ContentType);
        });
        return app;
    }

    [GeneratedRegex("^[a-z0-9-]{1,60}$")]
    private static partial Regex SafeName();
}
builder.Services.AddMemoryCache(options => options.SizeLimit = 64 * 1024 * 1024);
builder.Services.AddSingleton<CoverImageService>();

İki karar önemli. Birincisi izinli genişlik listesi: ?w='ye gelen her sayıyı kabul eden bir endpoint, herkesin binlerce farklı boyut isteyip CPU'yu ve cache'i doldurmasına izin verir. İkincisi Vary: Accept: aynı URL Chrome'a AVIF, eski bir tarayıcıya JPEG dönüyor; aradaki bir cache (CDN, reverse proxy, tarayıcı) bunu bilmezse birinin AVIF'ini ötekine verir. Endpoint'in ürettiği boyutlar:

original /covers/dune.jpg (1200x1800): 440.0 KB
width        jpeg      webp      avif
320      8.1 KB    3.9 KB    3.6 KB
480     13.6 KB    6.2 KB    5.5 KB
960     58.1 KB   26.0 KB   16.4 KB
1200   132.0 KB  106.2 KB   44.4 KB
Content-Type: image/avif, Vary: Accept, Cache-Control: public, max-age=86400

Tahminin 5 KB ise doğru: 480 piksellik AVIF 5,5 KB, orijinalin yüzde birinden biraz fazla. Bu kapaklar düz renk geçişli ve az detaylı olduğu için oran çok yüksek; gerçek fotoğraflarda fark daha küçük çıkar. Bedeli CPU: ilk istekte görsel kodlanıyor. Ayrı bir denemede 480 piksellik bir kapağı JPEG'e kodlamak 47 ms, AVIF'e kodlamak 124 ms sürdü; sonraki istekler cache'ten geliyor.

NgOptimizedImage Loader'ı, srcset ve sizes

Tarayıcının ekran boyutuna ve piksel yoğunluğuna göre doğru genişliği seçebilmesi için srcset ve sizes gerekiyor. Bunları elle yazmak yerine NgOptimizedImage'a bir loader veriyoruz; loader, kaynak adresi ve genişliği görselin gerçek URL'sine çeviriyor:

import { ImageLoaderConfig } from '@angular/common';

// Bookstore.Api'nin ürettiği genişlikler; NgOptimizedImage srcset'i aynı listeden kurar
export const COVER_WIDTHS = [160, 320, 480, 640, 960, 1200];

// 480 px'te '/covers/dune.jpg' -> '/img/covers/dune?w=480' (Bookstore.Api küçültür ve kodlar)
export function coverLoader(config: ImageLoaderConfig): string {
  const name = config.src.replace(/^\/covers\//, '').replace(/\.jpg$/, '');
  return `/img/covers/${name}?w=${config.width ?? 1200}`;
}
    provideClientHydration(),
    { provide: IMAGE_LOADER, useValue: coverLoader },
    { provide: IMAGE_CONFIG, useValue: { breakpoints: COVER_WIDTHS } },

Breakpoint listesi API'deki izinli genişliklerle aynı; böylece tarayıcının isteyebileceği her URL sunucuda karşılık buluyor. Template'te yalnız sizes ekleniyor: görselin hangi ekran genişliğinde kaç CSS piksel yer kapladığını söylüyor.

        <!-- Gerçek bir <img>: keşfedilebilir, önceliklendirilebilir; priority = fetchpriority="high" + lazy loading yok -->
        <img class="hero-cover" [ngSrc]="book.coverUrl!" width="1200" height="1800" priority
             sizes="(max-width: 640px) 100vw, 360px" [alt]="book.title" />
            <img [ngSrc]="book.coverUrl!" width="1200" height="1800"
                 sizes="(max-width: 640px) 50vw, 180px" [alt]="book.title" />

Prerender edilmiş HTML'deki kapak artık şöyle:

<img width="1200" height="1800" priority="" sizes="(max-width: 640px) 100vw, 360px" class="hero-cover" alt="Dune" loading="eager" fetchpriority="high" decoding="sync" ng-img="true" src="/img/covers/dune?w=1200" srcset="/img/covers/dune?w=160 160w, /img/covers/dune?w=320 320w, /img/covers/dune?w=480 480w, /img/covers/dune?w=640 640w, /img/covers/dune?w=960 960w, /img/covers/dune?w=1200 1200w">

Pixel 7 profilinde ekran 412 CSS piksel ve piksel yoğunluğu 2,625; tarayıcı 412 × 2,625 ≈ 1.082 piksel için w=1200'ü seçiyor (AVIF olarak 44,4 KB). Yeni çıkanlardaki kapaklar 182 CSS piksel geniş, onlar için w=480 iniyor. NgOptimizedImage lazy görsellerde sizes'ın başına auto ekliyor; destekleyen tarayıcı (browser-compat-data'ya göre Chrome 126, Firefox 150, Safari 27) gerçek layout genişliğini kullanıyor.

Ölçüm: 13,7 sn'den 1,34 sn'ye

http://127.0.0.1:5300/ profile=mobile runs=5
run     TTFB    FCP    LCP    CLS
1        151    320   1352      0
2        151    300   1336      0
3        151    300   1352      0
4        151    300   1336      0
5        151    300   1336      0
med      151    300   1336      0
LCP subparts (median): ttfb=151 loadDelay=22 loadDuration=1146 renderDelay=18
LCP element: html>body>app-root>main>app-home-page>section.hero>img.hero-cover
resource                                                  start    end  render-blocking
/                                                             0    179  non-blocking
/fonts/inter-latin-400-normal.woff2                         174    893  non-blocking
/fonts/inter-latin-700-normal.woff2                         175    939  non-blocking
/img/covers/dune?w=1200                                     175   1320  non-blocking
/styles-EHOBXKHN.css                                        176    360  non-blocking
/main-VDO5CNCM.js                                           181   1762  non-blocking
/img/covers/emma?w=480                                      299    606  non-blocking
/img/covers/the-hobbit?w=480                                299    682  non-blocking
/img/covers/middlemarch?w=480                               299    946  non-blocking
/img/covers/frankenstein?w=480                              299    998  non-blocking
/img/covers/pride-and-prejudice?w=480                       300   1185  non-blocking
/img/covers/moby-dick?w=480                                 300   1275  non-blocking
/img/covers/jane-eyre?w=480                                 300   1290  non-blocking
/img/covers/war-and-peace?w=480                             300   1329  non-blocking
analytics.thirdparty.test:5302/analytics.js                1826   2460  non-blocking
/img/covers/emma?w=640                                     1836   1849  non-blocking
/img/covers/the-hobbit?w=640                               1836   1849  non-blocking
/img/covers/middlemarch?w=640                              1837   1849  non-blocking
/img/covers/frankenstein?w=640                             1837   1849  non-blocking
/img/covers/pride-and-prejudice?w=640                      1838   1849  non-blocking
/img/covers/moby-dick?w=640                                1838   1849  non-blocking
/img/covers/jane-eyre?w=640                                1839   1850  non-blocking
/img/covers/war-and-peace?w=640                            1839   1850  non-blocking
analytics.thirdparty.test:5302/collect                     2524   2781  non-blocking
FCP 304 ms, LCP 1336 ms

LCP 13.692 ms'den 1.336 ms'ye indi; load duration 13,5 sn'den 1,15 sn'ye. Dokuz kapağın toplamı yaklaşık 4,3 MB'tan 90 KB civarına düştü ve LCP görseli artık hattı neredeyse tek başına kullanıyor. Waterfall'da ilginç bir ayrıntı var: hydration sırasında yeni çıkanların sekizi için w=640 istekleri başlıyor ve 10-15 ms içinde bitiyor. Aynı yüklemeyi DevTools protokolüyle izlediğimizde bu isteklerin net::ERR_ABORTED ile iptal edildiğini ve görsellerin currentSrc'sinin w=480'de kaldığını gördük; byte maliyeti yok ama RUM'da (gerçek kullanıcı ölçümü) ya da sunucu loglarında görürsen sebebi bu. Lighthouse'un görsel teslimi insight'ı da değişikliği doğruluyor: önceki yazının son hâlinde "Est savings of 3,960 KiB" diyordu, şimdi bir öneri bulamıyor.

Aynı değişiklik ilk yazının RUM tablosundaki bir sorunu da kapattı. Kitap detay sayfasındaki kapak boyutsuz bir <img> idi ve lab simülasyonunda telefonlarda CLS (Cumulative Layout Shift; beklenmedik kaymaların toplam puanı) p75'i 0,32 çıkmıştı. NgOptimizedImage width ve height istediği için tarayıcı görsel inmeden yerini ayırıyor; aynı sayfanın üç koşusunda CLS 0. Kaymaların ayrıntılı teşhisi CLS yazısında.

Fontlar: WOFF2, Origin ve font-display

İkinci yazıda Inter'i kendi origin'imizden sunmaya başlamıştık; WOFF2 (web için sıkıştırılmış font formatı) bugün yeterli tek format ve font-display: swap ile metin font gelene kadar fallback font'la görünüyor. Font tarafında iki iş kaldı: doğru karakter alt kümesini (subset) yüklemek ve font değişirken sayfanın kaymasını azaltmak. İkisini de ana sayfadan bağımsız küçük bir lab sayfasında ölçtük: Playwright sayfayı kendisi sunuyor, font dosyaları metin çizildikten 800 ms sonra geliyor ve CDP (Chrome DevTools Protocol; DevTools'un tarayıcıyı yönettiği protokol) ile paragrafı hangi font'un çizdiğini soruyoruz. Konusu Türkçe karakterler olduğu için bu sayfanın metni Türkçe:

  await tab.goto('http://fontlab.test/');
  await tab.waitForTimeout(2500);
  const cdp = await context.newCDPSession(tab);
  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: '#text' });
  const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId });
  const drawn = fonts.map((f) => `${f.familyName} ${f.glyphCount}`).join(', ');

Türkçe Karakterler ve unicode-range

scenario                       font bytes   glyphs drawn for <p>                  CLS
latin only                        46.9 KB   Arial 8, Inter 180                   0.0092
latin + latin-ext                116.5 KB   Inter 188                            0.0092
tr subset                         31.7 KB   Inter 188                            0.0092
tr subset + fallback metrics      31.7 KB   Inter 188                            0.0040

İlk satır en sık yapılan hatayı gösteriyor. Google Fonts ve Fontsource bir font'u alfabe gruplarına bölüyor: latin alt kümesi ASCII'yi (temel İngilizce karakter seti), Latin-1'i (ç, ö, ü) ve tek başına ı'yı içeriyor ama ğ, ş, Ş ve İ latin-ext'te. Yalnız latin'i yüklersen tarayıcı bu dört harfi fallback font'tan çiziyor: paragrafta 8 glif Arial'dan, gerisi Inter'den. Aynı kelimenin içinde iki farklı font görünüyor ("Kitaplı" Inter, "ğ" Arial). Görsel olarak fark edilmesi zor olabilir ama kalın başlıklarda ve büyük puntoda hemen göze batar.

İkinci satır doğru kurulum: iki alt küme unicode-range ile tanımlı ve tarayıcı sayfada geçen karakterlere göre gerekenleri indiriyor. Bedeli byte: Türkçe metin her iki alt kümeyi de istediği için iki ağırlıkta dört dosya, 116,5 KB. Üçüncü satır kendi alt kümemiz: Inter'in tam font'undan yalnız ASCII, Latin-1, Türkçe harfler ve birkaç noktalama işaretini fontTools'un pyftsubset aracıyla çıkardık:

$ pip install fonttools brotli
$ pyftsubset node_modules/inter-ui/web/Inter-Regular.woff2 --flavor=woff2 --output-file=fonts/inter-tr-400.woff2 \
    --unicodes="U+0020-007E,U+00A0-00FF,U+011E-011F,U+0130-0131,U+015E-015F,U+2013-2014,U+2018-201D,U+2026,U+20AC,U+20BA"

Ağırlık başına yaklaşık 16 KB; iki ağırlık 31,7 KB. Bunun karşılığı da var: alt kümede olmayan bir karakter (bir Lehçe ya da Almanca harf, bir emoji) yine fallback'e düşer. Kullanıcı içeriği gösteren bir sayfada latin-ext'i unicode-range ile yedek olarak tutmak güvenli bir orta yol.

Fallback Metrikleriyle Font Kaynaklı Kaymayı Azaltmak

Tablonun son sütunu CLS. font-display: swap metni hemen fallback font'la gösteriyor; Inter geldiğinde harf genişlikleri ve satır yükseklikleri değişiyor, satırlar farklı yerden kırılıyor ve altındaki her şey kayıyor. Çözüm, fallback font'u Inter ile aynı yeri kaplayacak şekilde ölçeklemek: size-adjust genişliği, ascent-override, descent-override ve line-gap-override dikey ölçüleri eşitliyor. Değerleri iki font'un tablolarından hesaplayan küçük bir script yazdık:

# Genişliklerin ortalaması alınan metin: Türkçe dahil gerçek Bookstore metninin harfleri
SAMPLE = "Bookstore haftanın kitabı: Dune. Kitaplığımızda Şubat ayında İstanbul'dan gelen yeni çıkanlar var."


def average_width(font: TTFont) -> float:
    cmap, hmtx, upem = font.getBestCmap(), font["hmtx"], font["head"].unitsPerEm
    widths = [hmtx[cmap[ord(c)]][0] / upem for c in SAMPLE if ord(c) in cmap]
    return sum(widths) / len(widths)


web, fallback = TTFont(sys.argv[1]), TTFont(sys.argv[2])
size_adjust = average_width(web) / average_width(fallback)
hhea, upem = web["hhea"], web["head"].unitsPerEm
print(f"size-adjust: {size_adjust * 100:.2f}%;")
print(f"ascent-override: {hhea.ascent / upem / size_adjust * 100:.2f}%;")
print(f"descent-override: {-hhea.descent / upem / size_adjust * 100:.2f}%;")
print(f"line-gap-override: {hhea.lineGap / upem / size_adjust * 100:.2f}%;")
$ python fallback-metrics.py Inter-Regular.woff2 arial.ttf
size-adjust: 105.53%;
ascent-override: 91.80%;
descent-override: 22.86%;
line-gap-override: 0.00%;

arial.ttf burada Arial'ın çalışma klasörüne kopyalanmış hâli; Windows'ta C:\Windows\Fonts\arial.ttf. Sonuç styles.css'e bir fallback @font-face olarak giriyor:

/* Inter ile aynı yeri kaplayacak şekilde ölçeklenmiş Arial: font değişimi daha az kaydırır (değerler lab/fallback-metrics.py'den) */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 105.53%;
  ascent-override: 91.8%;
  descent-override: 22.86%;
  line-gap-override: 0%;
}
body { margin: 0; font-family: Inter, 'Inter Fallback', system-ui, sans-serif; color: #0f172a; background: #f8fafc; line-height: 1.5; }

Lab sayfasında kayma 0,0092'den 0,0040'a indi. Bu sayfada iki değer de "good" eşiğinin (0,1) çok altında, çünkü font yalnız birkaç satırın yerini değiştiriyor; uzun metinli sayfalarda, dar ekranlarda ve başka kaymalarla toplandığında fark büyüyor. İkinci yazıdaki @font-face'lerle birlikte bakınca uygulamanın font zinciri şu: önce Inter, Inter yokken ölçeklenmiş Arial, Arial da yoksa sistem font'u. Ana sayfanın son ölçümünde CLS 0, LCP 1,35 sn.

Üretimde Dikkat

Görselleri her istekte kodlamak yerine bir image CDN'e (Cloudflare Images, imgix, Cloudinary gibi) bırakmak da yaygın bir seçenek. NgOptimizedImage bunların çoğu için hazır loader'lar sunuyor; bizim yazdığımız loader'ın yerini tek satırla alıyorlar. Kendi endpoint'ini sunuyorsan önüne bir cache koy ve Vary: Accept'in o cache'te desteklendiğinden emin ol; CDN'ler Vary'yi farklı ele alıyor (bazıları yalnız Accept-Encoding'i dikkate alıyor), kullandığın CDN'in dokümanında bunu kontrol et. Bizim IMemoryCache örneği aynı anda gelen iki istekte görseli iki kez kodlayabilir; yüksek trafikte kodlamayı build ya da yükleme anına taşımak, ya da tek seferlik bir kilit eklemek gerekir.

Fallback metrikleri fallback font'a bağlı. local('Arial') Arial'ın kurulu olmadığı cihazlarda işe yaramaz; Android'in varsayılan sans-serif font'u Roboto ve orada metin büyük olasılıkla ölçeklenmemiş sistem font'uyla çiziliyor (bunu bu lab'da ölçmedik). Mobil trafiğin önemliyse aynı script'i Roboto için de çalıştırıp ikinci bir fallback @font-face ekle. Son olarak preload'lara dikkat: beasties prerender edilen sayfada kullanılan fontlar için preload link'ini kendisi ekliyor (önceki yazıda gördük); buna elle bir font preload'u daha eklemek, LCP görseliyle aynı hatta bir yarışçı daha demek.

Sık Düşülen Tuzaklar

  • Türkçe sitede yalnız latin alt kümesini yüklemek; ğ, ş, Ş, İ fallback font'tan çizilir.
  • Kendi subset'ini çıkarırken İ/ı çiftini ya da büyük harfleri (Ğ, Ş) unutmak.
  • Yüzlerce ikonu tek bir ikon font'unda sunmak; sayfa üç ikon için yüzlerce KB indirir. Kullanılan ikonları SVG (vektör görsel formatı) olarak koymak daha ucuz.
  • ?w= parametresini sınırsız bırakmak; herkes CPU'nu ve cache'ini istediği gibi doldurabilir.
  • Formatı Accept'e göre seçip Vary: Accept'i unutmak.
  • sizes'ı yanlış yazmak; tarayıcı gereğinden büyük ya da küçük görseli seçer. Görselin gerçek CSS genişliğini ölçüp yaz.
  • Görselleri width/height olmadan kullanmak; inen her görsel altındaki içeriği iter.

Küçük Bir Deneme

★☆☆ cover-bytes.mjs'i frankenstein için çalıştır ve AVIF ile WebP farkını Dune'la karşılaştır. Sonra CoverImageService'te AVIF kalitesini 35'e indirip görseli tarayıcıda aç; fark gözle görülüyor mu?

★★☆ font-check.mjs'e <h1>'i de ölçen bir satır ekle ve yalnız latin alt kümesiyle "Şubat İndirimi" başlığını kalın yaz. Hangi harfler Arial'dan geliyor?

★★★ fallback-metrics.py'yi Roboto için çalıştır (Roboto'yu Fontsource'tan indirebilirsin), ikinci bir fallback @font-face ekle ve font-check.mjs'teki dördüncü senaryoyu local('Roboto') ile tekrarla.

LCP 1,35 sn, CLS 0: yükleme tarafında ana sayfa artık iyi. RUM tablosunda kırmızı kalan son satır katalogdaki INP (Interaction to Next Paint; bir etkileşimden sonraki ilk paint'e kadar geçen süre): 2.000 kitaplık listede arama kutusuna yazmak telefonda 232 ms sürüyordu. Bir sonraki yazıda bir etkileşimin üç fazına (input delay, processing, presentation delay) bakacak, long task'ları scheduler.yield() ile bölecek ve Angular'da listeyi computed, doğru track ve virtual scroll ile akıcı hale getireceğiz.