Mert Özen Her Satırda Daha İleri
Bilgisayar Bilimi Temelleri Networking ve HTTP Nasıl Çalışır — #07

HTTP Caching ve Conditional Request'ler: Cache-Control, ETag ve 304

Mert Özen 8 Kas 2026 9 dk 6 görüntülenme
HTTP Caching ve Conditional Request'ler: Cache-Control, ETag ve 304

HTTP cache, Cache-Control ile kopyanın ne kadar taze kalacağına, ETag veya Last-Modified ile süresi dolunca 304 Not Modified ile ucuzca doğrulanmasına karar verir. no-cache ile no-store farkı, Cache-Control'süz statik dosyaların heuristic freshness'ı, deploy sonrası eski index.html bug'ı ve HttpClient'ın kendi cache'i olmadığı için yazılan bir ETag handler'ı.

Sürüm notu: Bu yazıdaki kodlar ve istekler .NET 10 (ASP.NET Core 10 Kestrel) ve curl 8.7.1 ile denenmiştir; testler xUnit v3 4.0.2 ile koşulmuştur. HTTP (Hypertext Transfer Protocol) caching anlatımı RFC (Request for Comments) 9111'i esas alır.

Önce Tahmin Et

Bookstore'un Angular uygulamasının yeni sürümünü deploy ettin. Sunucuda index.html yeni bundle dosyalarına işaret ediyor, eski dosyalar silindi. Bir saat sonra destek ekibi yazıyor: bazı kullanıcılar beyaz bir ekran görüyor, konsolda 404 alan bir main.js var. Sunucuda her şey doğru. Bu kullanıcıların tarayıcısı hangi dosyayı nereden aldı?

İkinci soru: bir .NET servisi, katalog servisinin Cache-Control: public, max-age=60 dönen bir endpoint'ini HttpClient ile arka arkaya üç kez çağırıyor. Katalog servisi kaç istek görür?

Freshness: Cache-Control ve max-age

HTTP cache'in iki temel kararı var: elimdeki kopya hâlâ kullanılabilir mi (freshness) ve değilse, sunucuya "hâlâ aynı mı?" diye ucuza sorabilir miyim (validation). Freshness'ı sunucu Cache-Control header'ıyla belirler. Katalog endpoint'imin cevabı:

$ curl -si http://127.0.0.1:5491/cache/catalog
HTTP/1.1 200 OK
Content-Length: 311
Content-Type: application/json
Date: Sun, 08 Nov 2026 10:32:15 GMT
Server: Kestrel
Cache-Control: public, max-age=60
ETag: "bd54bce21b5e0aa3"
X-Server-Hits: 1

[{"Id":1,"Isbn":"978-0000000001","Title":"Title 1","Price":11},...]

max-age=60, cevabın üretildiği andan itibaren 60 saniye taze sayılacağını söyler; bu süre içinde bir cache aynı isteğe ağa hiç çıkmadan bu kopyayla cevap verir. public ise cevabın paylaşılan cache'lerde (CDN (Content Delivery Network), reverse proxy) de saklanabileceği anlamına gelir. Önemli direktifler:

DirektifAnlamıBookstore'da nerede
max-age=NN saniye tazeKatalog listesi, 60 s
s-maxage=NYalnız paylaşılan cache'ler için max-ageCDN'de 10 dakika, tarayıcıda 1 dakika
public / privatePaylaşılan cache saklayabilir miKullanıcının sepeti private
no-cacheSaklanabilir, ama her kullanımdan önce revalidate edilmeliindex.html
no-storeHiçbir yerde saklanmazÖdeme sayfası, kişisel veri
immutableTaze olduğu sürece revalidate etmeye bile gerek yokHash'li bundle dosyaları
stale-while-revalidate=NSüresi dolunca N saniye eskisini ver, arkada yenileAna sayfa vitrin listesi

Bir cevabın yaşını hesaplarken cache, aradaki proxy'lerin eklediği Age header'ını da hesaba katar: CDN'de 50 saniye bekledikten sonra tarayıcıya ulaşan bir cevabın max-age=60'ından geriye yalnızca 10 saniye kalır. Bu yüzden zincirde birden fazla cache varsa toplam bayatlık, tek tek max-age değerlerinin toplamı değil, en uzun yolun toplamıdır; içerik ne kadar bayat olabilir sorusunu bu zincire bakarak cevapla.

En sık karıştırılan ikili no-cache ve no-store. no-cache "cache'leme" demek değildir; "kullanmadan önce sor" demektir. Hiç saklanmaması gereken içerik için doğru direktif no-store.

Validation: ETag, Last-Modified ve 304

Kopyanın süresi dolduğunda cache onu hemen atmaz. Cevapta bir validator varsa sunucuya conditional request gönderir: "bende bu sürüm var, değişti mi?" Validator iki türlü olabilir. ETag, içeriğin sürümünü temsil eden opak bir değer; ben JSON'un (JavaScript Object Notation) Secure Hash Algorithm (SHA) ailesinden SHA-256 ile alınmış hash'inin ilk 16 karakterini kullanıyorum. Last-Modified ise içeriğin son değiştiği zaman. İstemci bunları If-None-Match ve If-Modified-Since ile geri gönderir:

$ curl -si -H 'If-None-Match: "bd54bce21b5e0aa3"' http://127.0.0.1:5491/cache/catalog
HTTP/1.1 304 Not Modified
Date: Sun, 08 Nov 2026 10:32:24 GMT
Server: Kestrel
Cache-Control: public, max-age=60
ETag: "bd54bce21b5e0aa3"
X-Server-Hits: 2

304 Not Modified: body yok, yalnızca header'lar. Cache elindeki kopyayı yeniden taze sayar ve onu kullanır. 311 byte'lık bir JSON için kazanç küçük; ama bir sayfanın onlarca CSS (Cascading Style Sheets), JavaScript ve resim dosyası için 304, megabaytlarca indirmeyi birkaç yüz byte'a indirir. Sunucu tarafındaki kod basit:

var catalogJson = System.Text.Json.JsonSerializer.Serialize(books);
var catalogEtag = $"\"{Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(catalogJson)))[..16].ToLowerInvariant()}\"";
var catalogHits = 0;
app.MapGet("/cache/catalog", (HttpContext ctx) =>
{
    ctx.Response.Headers["X-Server-Hits"] = Interlocked.Increment(ref catalogHits).ToString();
    ctx.Response.Headers.CacheControl = "public, max-age=60";
    ctx.Response.Headers.ETag = catalogEtag;
    if (ctx.Request.Headers.IfNoneMatch == catalogEtag)
    {
        return Results.StatusCode(StatusCodes.Status304NotModified);
    }
    return Results.Text(catalogJson, "application/json");
});

Statik dosyalarda bunu ASP.NET Core zaten yapıyor. UseStaticFiles() ile sunulan bir CSS dosyasının cevabı ve Last-Modified ile conditional isteği:

$ curl -sI http://127.0.0.1:5491/css/site.css
HTTP/1.1 200 OK
Content-Length: 73
Content-Type: text/css
Date: Sun, 08 Nov 2026 10:32:24 GMT
Server: Kestrel
Accept-Ranges: bytes
ETag: "1dd6f6c6b664b49"
Last-Modified: Sun, 08 Nov 2026 10:32:14 GMT

$ curl -sI -H "If-Modified-Since: Sun, 08 Nov 2026 10:32:14 GMT" http://127.0.0.1:5491/css/site.css
HTTP/1.1 304 Not Modified
Content-Type: text/css
...
ETag: "1dd6f6c6b664b49"
Last-Modified: Sun, 08 Nov 2026 10:32:14 GMT

Dikkat: statik dosya cevabında Cache-Control yok. Static Files middleware varsayılan olarak validator'ları ekler ama freshness belirtmez. Bu durumda RFC 9111 tarayıcıya heuristic freshness izni verir: tipik olarak dosyanın son değişikliğinden bu yana geçen sürenin yüzde 10'u kadar. Bir ay önce değişmiş bir dosya, sunucuya hiç sorulmadan üç gün cache'ten kullanılabilir. Tahmin bölümündeki sorunun anahtarı bu.

07-http-cache-tr.png

Gerçek Bug: Deploy Sonrası Eski index.html

Angular build'i dosya adlarına içerik hash'i ekler (main.3f9a1c.js); içerik değişince ad da değişir. index.html'in adı ise hiç değişmez ve hangi bundle'ların yükleneceğini o söyler. Tahmin bölümündeki kullanıcıların tarayıcısı eski index.html'i heuristic freshness ile cache'ten aldı, o da artık sunucuda olmayan eski main.js'i istedi: 404 ve beyaz ekran.

Doğru kural iki satır: index.html'e no-cache, hash'li dosyalara uzun bir max-age ve immutable. Demo sunucumda ikisini de taklit ettim:

$ curl -s -D - -o /dev/null http://127.0.0.1:5491/app/index.html
HTTP/1.1 200 OK
Content-Length: 59
Cache-Control: no-cache
ETag: "index-v42"
$ curl -s -D - -o /dev/null -H 'If-None-Match: "index-v42"' http://127.0.0.1:5491/app/index.html
HTTP/1.1 304 Not Modified
Cache-Control: no-cache
ETag: "index-v42"
$ curl -s -D - -o /dev/null http://127.0.0.1:5491/app/main.3f9a1c.js
HTTP/1.1 200 OK
Content-Length: 26
Cache-Control: public, max-age=31536000, immutable

index.html her açılışta revalidate ediliyor ama değişmediyse yalnızca bir 304 dönüyor; maliyet bir RTT (round-trip time). Bundle'lar bir yıl boyunca hiç sorulmadan kullanılıyor; değiştiklerinde adları da değişeceği için eski kopyanın kullanılma riski yok. ASP.NET Core'da bunu Static Files'ın OnPrepareResponse callback'iyle dosya adına göre ayarlarsın; bir CDN arkasındaysan aynı kuralları orada da uygulaman gerekir.

Bu denemede bir yan bulgu daha çıktı: curl'ün -I seçeneği HEAD gönderir ve minimal API'de (Application Programming Interface) MapGet ile tanımlı endpoint'ler HEAD'e 405 Method Not Allowed dönüyor; bu yüzden header'ları GET ile ve -D - ile okudum. Static Files ise HEAD'i destekliyor.

Vary ve Tarayıcıda Cache'i Görmek

Bir URL'in (Uniform Resource Locator) cevabı istekteki bir header'a göre değişiyorsa, sunucu bunu Vary ile bildirmek zorunda. Vary: Accept-Encoding, gzip ve brotli ile sıkıştırılmış kopyaların ayrı saklanmasını sağlar. Vary: Authorization veya Vary: Cookie unutulursa paylaşılan bir cache bir kullanıcının cevabını diğerine verebilir; kişiye özel cevaplarda en güvenli yol zaten private veya no-store.

Tarayıcıda cache'in çalıştığını DevTools'un Network panelinde Size sütunundan görürsün: (memory cache) ve (disk cache) ağa hiç çıkılmadığını, küçük bir boyutla gelen 304 ise revalidation yapıldığını gösterir. "Disable cache" kutusu, DevTools açıkken bütün bu kararları devre dışı bırakır; "bende çalışıyor" diyen geliştiricinin DevTools'u açıksa sebep çoğu zaman bu.

.NET: HttpClient'ın Cache'i Yok

Tahmin bölümündeki ikinci sorunun cevabı: üç istek. Tarayıcıların aksine HttpClient'ın bir HTTP cache'i yok; Cache-Control'u okumaz, her isteği sunucuya gönderir. İstemci tarafında revalidation istiyorsan kendin yazmalısın. ETag'i saklayıp sonraki GET'lerde If-None-Match gönderen küçük bir handler:

// HttpClient'ın kendi HTTP cache'i yok; bu handler GET'leri If-None-Match ile revalidate eder
public sealed class EtagCacheHandler : DelegatingHandler
{
    private sealed record Entry(EntityTagHeaderValue ETag, byte[] Body, MediaTypeHeaderValue? ContentType);

    private readonly ConcurrentDictionary<Uri, Entry> _entries = new();

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (request.Method != HttpMethod.Get || request.RequestUri is null)
        {
            return await base.SendAsync(request, cancellationToken);
        }

        _entries.TryGetValue(request.RequestUri, out var cached);
        if (cached is not null)
        {
            request.Headers.IfNoneMatch.Add(cached.ETag);
        }

        var response = await base.SendAsync(request, cancellationToken);
        if (response.StatusCode == HttpStatusCode.NotModified && cached is not null)
        {
            // 304: body'yi sunucudan değil, elimizdeki kopyadan üret
            var fromCache = new HttpResponseMessage(HttpStatusCode.OK)
            {
                RequestMessage = request,
                Content = new ByteArrayContent(cached.Body),
            };
            fromCache.Content.Headers.ContentType = cached.ContentType;
            foreach (var header in response.Headers)
            {
                fromCache.Headers.TryAddWithoutValidation(header.Key, header.Value);
            }
            fromCache.Headers.Add("X-Cache", "revalidated");
            response.Dispose();
            return fromCache;
        }

        if (response.IsSuccessStatusCode && response.Headers.ETag is { } etag)
        {
            var body = await response.Content.ReadAsByteArrayAsync(cancellationToken);
            _entries[request.RequestUri] = new Entry(etag, body, response.Content.Headers.ContentType);
            var copy = new ByteArrayContent(body);
            copy.Headers.ContentType = response.Content.Headers.ContentType;
            response.Content = copy;
            response.Headers.Add("X-Cache", "miss");
        }
        return response;
    }
}
$ dotnet Bookstore.Net.Lab.dll httpcache http://127.0.0.1:5491/cache/catalog
plain HttpClient:
  #1 status=200 bytes=311 serverHits=1 x-cache=-
  #2 status=200 bytes=311 serverHits=2 x-cache=-
  #3 status=200 bytes=311 serverHits=3 x-cache=-
with EtagCacheHandler:
  #1 status=200 bytes=311 serverHits=4 x-cache=miss
  #2 status=200 bytes=311 serverHits=5 x-cache=revalidated
  #3 status=200 bytes=311 serverHits=6 x-cache=revalidated

Düz HttpClient her seferinde 311 byte'lık body'yi tekrar indirdi. Handler'lı versiyonda ilk istekten sonra sunucu yine her isteği gördü (serverHits artıyor) ama 304 döndü; body kablodan geçmedi, istemciye elindeki kopyadan verildi. Handler'ın davranışı, sahte bir HttpMessageHandler ile yazılmış bir xUnit testinde doğrulanıyor: ikinci istekte If-None-Match: "v1" gönderiliyor ve body cache'ten geliyor. Bu örnek max-age'i uygulamıyor; tam bir istemci cache'i (freshness, Vary, boyut limiti, eviction) yazmak ciddi bir iş ve çoğu zaman doğru katman, servisler arasında duran bir cache veya sunucu tarafında Output Caching.

Sunucu tarafındaki caching seçenekleri ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Caching: IMemoryCache, Redis ve Response Caching" ve ".NET 7 ile ASP.NET Core Yenilikleri: MapGroup, TypedResults, Rate Limiting ve Output Caching" yazılarında. Kısa fark: Response Caching middleware bu yazıdaki HTTP header kurallarına göre çalışan bir HTTP cache; Output Caching ise sunucunun kendi kurallarıyla cevapları saklayan, istemcinin header'larından bağımsız bir sunucu cache'i.

Üretimde Dikkat

Her cevap tipi için bilinçli bir Cache-Control seç. Header'ı hiç göndermemek "cache'leme" demek değil; heuristic freshness'a ve aradaki her proxy'nin kendi kararına izin vermek demek.

Kişiye özel cevapları paylaşılan cache'ten uzak tut. Kimlik doğrulaması gerektiren endpoint'lerde private veya no-store kullan; bir CDN'in bir kullanıcının sepetini başkasına göstermesi, cache yapılandırmasının en pahalı hatası.

Invalidation'ı ad değiştirerek yap. Bir CDN'de cache'i temizlemek (purge) yavaş ve hataya açık; içerik hash'li dosya adları, eski ve yeni sürümün hiç karışmamasını garanti eder.

Sık Düşülen Tuzaklar

no-cache'i "hiç saklama" sanmak. Saklanır ama her seferinde revalidate edilir; hiç saklanmaması gereken içerik için no-store.

Statik dosyalarda Cache-Control'ü unutmak. Validator'lar var ama freshness yok; tarayıcı heuristic ile günlerce eski dosyayı kullanabilir.

index.html'e uzun bir max-age vermek. Deploy sonrası beyaz ekranın klasik sebebi.

HttpClient'ın tarayıcı gibi cache'lediğini varsaymak. Servisler arası çağrılarda hiçbir cache otomatik değil.

Cevap kullanıcıya göre değiştiği hâlde Vary göndermemek. Paylaşılan cache'te veri sızıntısına yol açar.

Küçük Bir Deneme

★☆☆ Kendi ASP.NET Core uygulamanda bir statik dosyaya curl ile istek at; ETag ve Last-Modified'ı al, sonra ikisiyle ayrı ayrı conditional istek gönder. İkisinde de 304 alıyor musun?

★★☆ UseStaticFiles'a, adında nokta ve hash bulunan dosyalara public, max-age=31536000, immutable, index.html'e no-cache ekleyen bir OnPrepareResponse yaz ve curl ile doğrula.

★★★ EtagCacheHandler'a max-age desteği ekle: kopya tazeyse sunucuya hiç gitmeden cevap versin. X-Server-Hits ile 60 saniye boyunca kaç istek gittiğini ölç ve xUnit testini genişlet.

Caching, aynı cevabı tekrar indirmemeyi sağlıyor. Ama ilk yazıdan beri bir sorunu erteledik: HTTP/1.1'de bir bağlantı aynı anda tek bir cevap taşıyabiliyor. Bir sonraki yazıda HTTP/2'ye geçiyoruz: binary framing, multiplexing, HPACK (HTTP/2'nin header sıkıştırma formatı) ve Kestrel'de HTTP/2.