Mert Özen Her Satırda Daha İleri
Frontend Browser API'leri ve PWA — #01

Fetch Derinlemesine: Streaming Response, AbortSignal.any, keepalive, fetchLater ve Angular 22'nin Fetch Backend'i

Mert Özen 15 Kas 2026 20 dk 6 görüntülenme
Fetch Derinlemesine: Streaming Response, AbortSignal.any, keepalive, fetchLater ve Angular 22'nin Fetch Backend'i

Büyük bir response'u beklemeden işlemek için response.body'yi TextDecoderStream ile satır satır okursun; timeout ile kullanıcı iptalini AbortSignal.any birleştirir, sayfa kapanırken veri için keepalive ve fetchLater var. Chromium 158 ve WebKit 27.2'de gerçek çıktılarla upload streaming'in HTTP/2 şartını, keepalive'ın 64 KiB sınırını, WebKit farklarını ve Angular 22 HttpClient'ında upload progress için route bazında withXhr() kullanmayı gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (TypeScript 6.0), Node.js 24 ve Playwright 1.65 ile denenmiştir; tarayıcı çıktıları Playwright'ın Chromium 158 ve WebKit 27.2 build'lerinden alınmıştır. Destek bilgileri MDN'in (Mozilla'nın web platformu dokümantasyonu) browser-compat-data 8.1.9 ve web-features 3.46'dan (Kasım 2026) okunmuştur; Firefox bu ortamda çalıştırılamadı, Firefox satırları bu verilerden gelir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'si kitap listesini dönüyor. Aşağıdaki fonksiyon aynı response'u önce JSON, sonra düz metin olarak okumaya çalışıyor:

export async function bodyTwice() {
  const response = await fetch("/api/books");
  const books = await response.json();
  console.log("json():", books.length, "books, bodyUsed =", response.bodyUsed);
  try {
    const text = await response.text();
    console.log("text():", text.length, "chars");
  } catch (err) {
    console.log(`text(): ${err.name}: ${err.message}`);
  }
}

İkinci satırda ne görürsün: 557 karakterlik metin mi, boş bir string mi, yoksa bir hata mı? Hata ise Chromium ve WebKit aynı mesajı mı verir?

fetch'in temelini, response.ok kontrolünü, CORS'u (tarayıcının başka bir origin'e giden isteği o sunucunun iznine bağlaması) ve AbortController'ı JavaScript Orta serisinin "JavaScript Fetch API: GET, POST, response.ok, Timeout ve CORS'a Giriş" yazısında görmüştük. Bu yazı oradan devam ediyor: response gövdesini stream olarak okuyacak, upload'ı stream'le gönderecek, kullanıcının iptaliyle timeout'u tek signal'da birleştirecek, sayfa kapanırken veri göndermenin üç yolunu karşılaştıracak ve Angular 22'nin HttpClient'ı neden artık Fetch kullandığına bakacağız. Örneklerin hepsi küçük bir sahte Bookstore API'si (api/server.ts, bağımlılıksız Node.js) ve Playwright'la sürülen gerçek tarayıcılarda çalıştı.

Request ve Response: Gövde Bir Kez Okunur

Cevap: hata. İki motor da TypeError fırlatıyor ama mesajları farklı:

Chromium 158:
  json(): 6 books, bodyUsed = true
  text(): TypeError: Failed to execute 'text' on 'Response': body stream already read
  json(): 6 books; clone text(): 557 chars
WebKit 27.2:
  json(): 6 books, bodyUsed = true
  text(): TypeError: Body is disturbed or locked
  json(): 6 books; clone text(): 557 chars

Response ve Request nesnelerinin gövdesi (body) bir ReadableStream. json(), text(), blob(), arrayBuffer() ve bytes() bu stream'i sonuna kadar okuyup tüketiyor; okuma başladığı anda bodyUsed true oluyor ve ikinci bir okuma imkânsız. Aynı gövdeye iki kez ihtiyacın varsa okumadan önce clone() çağırırsın:

export async function bodyTwiceWithClone() {
  const response = await fetch("/api/books");
  const copy = response.clone(); // ilk okumadan önce kopyala
  const books = await response.json();
  const text = await copy.text();
  console.log("json():", books.length, "books; clone text():", text.length, "chars");
}

clone() stream'i ikiye ayırıyor (tee). Bedeli şu: iki kopyadan biri yavaş okunursa, aradaki fark bellekte tutuluyor. Bir service worker'da response'u hem cache'e yazıp hem sayfaya döndürürken clone() zorunlu; normal uygulama kodunda ise gövdeyi bir kez okuyup sonucu değişkende tutmak daha ucuz.

Hata mesajına göre karar veren kod yazma: aynı hata Chromium'da "body stream already read", WebKit'te "Body is disturbed or locked" diyor. Kontrol etmen gereken şey response.bodyUsed.

response.body ile Büyük Raporu Satır Satır İşlemek

Bookstore'un back office ekranında sipariş raporunu CSV (virgülle ayrılmış düz metin) olarak indirip toplam ciroyu hesaplayan bir buton var. Sahte API 20.000 satırı 500'erlik parçalar hâlinde, parçalar arasında 20 ms bekleyerek yazıyor; gerçek bir rapor endpoint'inin veritabanından satır satır okuyup gönderdiği duruma benziyor. response.text() bütün gövde gelene kadar bekler. response.body ise chunk'lar geldikçe okunabilen bir stream:

// Metin chunk'larını satırlara böler; bir satır iki chunk arasında bölünmüş olabilir
export function splitLines() {
  let rest = "";
  return new TransformStream({
    transform(chunk, controller) {
      const lines = (rest + chunk).split("\n");
      rest = lines.pop();
      for (const line of lines) controller.enqueue(line);
    },
    flush(controller) {
      if (rest) controller.enqueue(rest);
    },
  });
}

export async function sumReport(url = "/api/reports/orders.csv?rows=20000") {
  const started = performance.now();
  const response = await fetch(url);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  let rows = 0;
  let total = 0;
  let firstRowMs = 0;
  const lines = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(splitLines());
  for await (const line of lines) {
    if (line.startsWith("orderId")) continue; // başlık satırı
    if (rows++ === 0) firstRowMs = performance.now() - started;
    total += Number(line.split(",")[4]);
  }
  const ms = performance.now() - started;
  console.log(`stream: first row after ${firstRowMs.toFixed(0)} ms, ${rows} rows, ${total} TRY in ${ms.toFixed(0)} ms`);
}

export async function sumReportWithText(url = "/api/reports/orders.csv?rows=20000") {

TextDecoderStream byte chunk'larını UTF-8 (Unicode metnin en yaygın byte kodlaması) metne çeviriyor; çok byte'lı bir karakter iki chunk'a bölünmüşse onu da doğru birleştiriyor. splitLines ise bir TransformStream: gelen metni satırlara bölüyor, yarım kalan son satırı bir sonraki chunk'a saklıyor. for await...of ile stream'i doğrudan dolaşmak (async iteration) Chrome 124 ve Firefox 110'dan beri vardı; Safari 27 ile Eylül 2026'da Baseline oldu. Daha eski Safari'leri destekliyorsan response.body.getReader() ve read() döngüsü aynı işi yapar. Aynı raporu iki yolla toplayınca:

Chromium: stream: first row after 3 ms, 20000 rows, 13666390 TRY in 870 ms
Chromium: text(): first row after 865 ms, 20000 rows, 13666390 TRY in 872 ms
WebKit:   stream: first row after 7 ms, 20000 rows, 13666390 TRY in 858 ms
WebKit:   text(): first row after 866 ms, 20000 rows, 13666390 TRY in 868 ms

Toplam süre neredeyse aynı, çünkü darboğaz sunucu. Fark ilk satırda: stream'le ilk satır 3–7 ms'de işleniyor, text() ile 865 ms sonra. Bu fark iki yerde işe yarıyor. İlki kullanıcıya erken sonuç göstermek ("şu ana kadar 5.000 satır"). İkincisi bellek: text() 20.000 satırın tamamını tek bir string'de tutup sonra bir de satır array'i üretiyor, stream'le ise aynı anda bellekte yalnız birkaç chunk var. Rapor 20.000 değil 2 milyon satır olduğunda tarayıcı sekmesini ayakta tutan ikinci yol oluyor.

Upload Streaming: duplex: 'half' ve HTTP/2 Şartı

Aynı fikir ters yönde de var: request gövdesi olarak bir ReadableStream vermek. Bookstore'un kapak yükleme ekranında dosyayı bir TransformStream'den geçirip ağ katmanının kaç byte çektiğini sayarsan, XHR (XMLHttpRequest; Fetch'ten önceki istek API'si) olmadan upload progress elde edersin:

// Request stream'leri duplex: "half" ister; desteklemeyen tarayıcı getter'ı okumaz
// ve stream'i "[object ReadableStream]" string'ine çevirir (bu da Content-Type ekler)
export const supportsRequestStreams = (() => {
  let duplexRead = false;
  const request = new Request("/", {
    method: "POST",
    body: new ReadableStream(),
    get duplex() {
      duplexRead = true;
      return "half";
    },
  });
  return duplexRead && !request.headers.has("Content-Type");
})();

// Ağ katmanının stream'den çektiği byte'ları sayar: XHR olmadan upload progress
function countBytes(onProgress) {
  let sent = 0;
  return new TransformStream({
    transform(chunk, controller) {
      sent += chunk.byteLength;
      onProgress(sent);
      controller.enqueue(chunk);
    },
  });
}

export async function uploadCoverStream(isbn, file, base = "") {
  const body = file.stream().pipeThrough(countBytes((sent) => console.log(`sent ${sent} of ${file.size}`)));
  const response = await fetch(`${base}/api/covers/${isbn}`, {
    method: "POST",
    headers: { "Content-Type": file.type },
    body,
    duplex: "half",
  });
  return response.json();
}

duplex: "half" zorunlu: tarayıcıya response'u, request gövdesinin tamamı gönderildikten sonra okuyacağını söylüyor. Bu option'ı tanımayan bir tarayıcı stream'i String()'e çevirip "[object ReadableStream]" metnini gönderir; feature detection bu yüzden hem getter'ın okunup okunmadığına hem de Content-Type header'ının eklenip eklenmediğine bakıyor. Üç denemenin sonucu:

HTTP/2 (https://localhost:5282), Chromium:
  sent 262144 of 3000000
  sent 1310720 of 3000000
  sent 2097152 of 3000000
  sent 3000000 of 3000000
  => {"isbn":"9780441172719","bytes":3000000,"chunks":247}
  [api] #36 POST /api/covers/9780441172719 HTTP/2
HTTP/1.1 (http://localhost:5281), Chromium:
  Failed to load resource: net::ERR_ALPN_NEGOTIATION_FAILED
  => "TypeError: Failed to fetch"
HTTP/1.1, WebKit:
  => true
  sent 3000000 of 3000000
  Failed to load resource: request body stream exhausted
  => "TypeError: Load failed"

Chromium bu özelliği yalnız HTTP/2 ve üstünde destekliyor: aynı kod HTTP/1.1 sunucuya ERR_ALPN_NEGOTIATION_FAILED ile düşüyor. Testi HTTP/2 için kendi imzaladığım (self-signed) bir sertifikayla, sahte API'yi TLS_KEY ve TLS_CERT değişkenleriyle başlatarak yaptım. WebKit tarafı daha öğretici: Playwright'ın WebKit 27.2 build'inde feature detection true döndü, stream sonuna kadar okundu, ama istek "request body stream exhausted" ile başarısız oldu. MDN verisine göre fetch'in duplex option'ı Safari'de yok, Firefox'ta yalnız preview sürümünde var. Yani request streaming bugün Chromium'a özgü bir iyileştirme. Ona güvenen bir upload ekranı her zaman catch içinde stream'siz, düz File gövdeli bir fallback'e dönmeli.

AbortSignal.any ile Timeout ve Kullanıcı İptalini Birleştirmek

JavaScript İleri serisinin "JavaScript İleri Asenkron Pattern'lar: Promise.all Ailesi, İptal, Retry ve Timeout" yazısında iptal ve timeout pattern'lerini görmüştük. Fetch tarafında iki hazır parça var: AbortSignal.timeout(ms) süresi dolunca kendiliğinden abort olan bir signal üretiyor, AbortSignal.any([...]) ise verdiğin signal'lardan biri abort olduğunda abort olan birleşik bir signal. İkisi de Baseline (any: Mart 2024'ten beri, Chrome 116, Firefox 124, Safari 17.4). İlk sürüm:

// Kullanıcının iptal butonu ve timeout, tek bir signal'da birleşiyor
export async function loadReport(url, userSignal, timeoutMs = 5000) {
  const signal = AbortSignal.any([userSignal, AbortSignal.timeout(timeoutMs)]);
  const response = await fetch(url, { signal });
  return response.text(); // signal gövdenin okunmasını da iptal eder
}

export async function abortScenarios() {
  const cases = [
    ["slow server, 1 s timeout", "/api/reports/orders.csv?rows=10&delay=3000", 1000, null],
    ["user cancels after 300 ms", "/api/reports/orders.csv?rows=10&delay=3000", 5000, 300],
    ["cancel while the body streams", "/api/reports/orders.csv?rows=50000", 5000, 300],
    ["fast server", "/api/reports/orders.csv?rows=10", 1000, null],
  ];
  for (const [name, url, timeoutMs, cancelAfterMs] of cases) {
    const controller = new AbortController();
    if (cancelAfterMs) setTimeout(() => controller.abort(), cancelAfterMs);
    const started = performance.now();
    try {
      const csv = await loadReport(url, controller.signal, timeoutMs);
      console.log(`${name}: ok, ${csv.length} chars`);
    } catch (err) {
      const ms = (performance.now() - started).toFixed(0);
      console.log(`${name}: ${err.name} after ${ms} ms (${err.message})`);
    }
  }
}
Chromium: slow server, 1 s timeout: TimeoutError after 1003 ms (signal timed out)
Chromium: user cancels after 300 ms: AbortError after 301 ms (signal is aborted without reason)
Chromium: cancel while the body streams: AbortError after 302 ms (signal is aborted without reason)
Chromium: fast server: ok, 360 chars
WebKit:   slow server, 1 s timeout: ok, 360 chars
WebKit:   user cancels after 300 ms: AbortError after 307 ms (Fetch is aborted)
WebKit:   cancel while the body streams: AbortError after 301 ms (Fetch is aborted)
WebKit:   fast server: ok, 360 chars

Chromium'da her şey beklendiği gibi: timeout TimeoutError, kullanıcının iptali AbortError veriyor ve signal gövde okunurken de çalışıyor (üçüncü satır 50.000 satırlık raporu 302. ms'de kesti). Spesifikasyona göre fetch, signal'ın reason'ıyla reject ediyor; timeout'un reason'ı bir TimeoutError DOMException'ı.

WebKit'te iki sorun çıktı. Birincisi, birleşik signal'la yapılan isteğin timeout'u hiç tetiklenmedi: istek üç saniye bekleyip başarıyla bitti. Taze sayfalarda tek tek denediğimde örüntü netti: AbortSignal.any'nin döndürdüğü signal'a yalnız fetch referans veriyorsa, yaklaşık bir saniyeden sonraki abort isteğe ulaşmıyor (200 ms'lik timeout çalıştı, 1.000 ms'lik ve 2.000 ms'de yapılan elle iptal çalışmadı). Signal'a bir abort listener'ı eklediğim anda düzeldi; spesifikasyon listener'ı olan birleşik signal'ın garbage collector tarafından toplanmasını yasaklıyor, sorun büyük ihtimalle bu toplama. İkincisi, WebKit tek başına AbortSignal.timeout kullanıldığında bile isteği TimeoutError yerine AbortError ile reject ediyor. Bunlar Playwright'ın WebKit build'inde gördüğüm davranışlar; gerçek Safari'de de denemeden üretime güvenme. Her iki tarayıcıda doğru çalışan sürüm:

// Kullanıcının iptal butonu ve timeout, tek bir signal'da birleşiyor
export async function loadReport(url, userSignal, timeoutMs = 5000) {
  const signal = AbortSignal.any([userSignal, AbortSignal.timeout(timeoutMs)]);
  // listener, WebKit'te birleşik signal'ı da hayatta tutar (aşağıdaki nota bak)
  signal.addEventListener("abort", () => console.log(`  abort event: ${signal.reason.name}`), { once: true });
  try {
    const response = await fetch(url, { signal });
    return await response.text(); // signal gövdenin okunmasını da iptal eder
  } catch (err) {
    // WebKit timeout'ta da AbortError ile reject ediyor; signal'ın reason'ı güvenilir
    throw signal.aborted ? signal.reason : err;
  }
}
  abort event: TimeoutError
slow server, 1 s timeout: TimeoutError after 1008 ms
  abort event: AbortError
user cancels after 300 ms: AbortError after 302 ms
  abort event: AbortError
cancel while the body streams: AbortError after 303 ms
fast server: ok, 360 chars

Bu çıktı WebKit'ten; Chromium'unki aynı. Hangi signal'ın tetiklendiğini hata adından değil signal.reason'dan okumak, iki tarayıcı farkını da ortadan kaldırıyor.

cache, credentials, mode ve priority

fetch'in ikinci argümanı çoğu zaman yalnız method, headers ve body için kullanılıyor, oysa davranışı belirleyen birkaç option daha var:

OptionVarsayılanNe zaman değiştirirsin
cache"default"HTTP cache'i atlamak ("no-store"), her seferinde sunucuya sormak ("no-cache")
credentials"same-origin"Başka origin'deki API'ye cookie göndermek ("include", sunucuda CORS izni de gerekir)
mode"cors"Yalnız aynı origin'e izin vermek ("same-origin"); "no-cors" okunamayan (opaque) bir response verir
priority"auto"Ekranın ilk açılışında gereken isteği öne almak ("high"), analytics'i geriye atmak ("low")
keepalivefalseSayfa kapanırken de tamamlanması gereken küçük istekler (aşağıda)

cache en çok yanlış anlaşılanı. Sahte API /api/books'a Cache-Control: max-age=60 ve bir ETag ile cevap veriyor; aynı isteği altı farklı modla gönderip sunucu log'una baktım:

// /api/books, Cache-Control: max-age=60 ve bir ETag ile cevap veriyor
export async function cacheModes() {
  for (const cache of ["default", "default", "no-cache", "no-store", "reload", "force-cache"]) {
    const response = await fetch("/api/books", { cache });
    console.log(`cache: "${cache}" -> ${response.status}`);
    await new Promise((resolve) => setTimeout(resolve, 100)); // sunucu log'u sırayla gelsin
  }
}
cache: "default" -> 200
cache: "default" -> 200
cache: "no-cache" -> 200
cache: "no-store" -> 200
cache: "reload" -> 200
cache: "force-cache" -> 200
[api] #69 GET /api/books
[api] #69 200 557 bytes
[api] #70 GET /api/books (If-None-Match)
[api] #70 304 Not Modified
[api] #71 GET /api/books
[api] #71 200 557 bytes
[api] #72 GET /api/books
[api] #72 200 557 bytes

Sayfa altı kez 200 gördü, sunucuya ise dört istek ulaştı. İkinci "default" ve "force-cache" tarayıcının HTTP cache'inden döndü. "no-cache" cache'i kullanmamak demek değil: tarayıcı If-None-Match ile sunucuya sordu, 304 aldı ve sayfaya cache'teki gövdeyi 200 olarak verdi. "no-store" ve "reload" cache'i hiç okumadı. Bu sonucu Chromium'da aldım; Playwright'ın WebKit build'inde altı isteğin altısı da sunucuya gitti, bu yüzden cache davranışını Safari'de ayrıca test et. priority Chrome 101, Firefox 132 ve Safari 17.2'den beri destekleniyor; bir öncelik ipucu olduğu için desteklemeyen tarayıcı onu yok sayar, bir şey bozulmaz.

Sayfa Kapanırken Veri Göndermek: sendBeacon, keepalive ve fetchLater

Bookstore kitap detay sayfasında kullanıcının sayfada ne kadar kaldığını ölçmek istiyor. Normal bir fetch sayfa kapanırken iptal edilir. Bunun için üç araç var: navigator.sendBeacon (eski, yalnız POST, response okunamaz), fetch(..., { keepalive: true }) (method ve header seçebilirsin) ve fetchLater (isteği önceden kaydedersin, tarayıcı sayfa kapanırken gönderir). İlk ikisinin ortak bir sınırı var:

// keepalive istekleri, henüz yolda olan gövdeler için ortak 64 KiB'lık bir bütçeyi paylaşır
export async function keepaliveLimit() {
  for (const size of [1000, 70000]) {
    const body = "x".repeat(size);
    const beacon = navigator.sendBeacon("/api/events", body);
    console.log(`sendBeacon(${size} bytes) -> ${beacon}`);
    try {
      await fetch("/api/events", { method: "POST", body, keepalive: true });
      console.log(`fetch keepalive (${size} bytes) -> sent`);
    } catch (err) {
      console.log(`fetch keepalive (${size} bytes) -> ${err.name}: ${err.message}`);
    }
  }
}
Chromium: sendBeacon(1000 bytes) -> true
Chromium: fetch keepalive (1000 bytes) -> sent
Chromium: sendBeacon(70000 bytes) -> false
Chromium: fetch keepalive (70000 bytes) -> TypeError: Failed to fetch
WebKit:   sendBeacon(1000 bytes) -> true
WebKit:   fetch keepalive (1000 bytes) -> sent
WebKit:   sendBeacon(70000 bytes) -> false
WebKit:   fetch keepalive (70000 bytes) -> TypeError: Load failed

Fetch Standard'a göre bir sayfanın henüz tamamlanmamış keepalive isteklerinin gövdeleri toplamda 64 KiB'ı geçemez; sendBeacon da aynı bütçeyi kullanıyor. 70.000 byte'lık gövdede sendBeacon false döndü, fetch ise TypeError ile reject etti ve istek sunucuya hiç gitmedi. WebKit konsola sebebini de yazdı: "Reached maximum amount of queued data of 64Kb for keepalive requests". Sınır tek isteğe değil, aynı anda yolda olan bütün keepalive isteklerine uygulanıyor; sayfa kapanırken beş ayrı event'i ayrı ayrı gönderen kod bu bütçeyi beklenenden önce bitirir.

Asıl zor kısım zamanlama. unload ve beforeunload mobilde güvenilir şekilde çalışmıyor; önerilen yol visibilitychange ile sayfa hidden olduğunda göndermek. fetchLater bu işi tarayıcıya bırakıyor: isteği baştan kaydediyorsun, veri değiştikçe eskisini abort edip yenisini kaydediyorsun, tarayıcı sayfa kapanırken ya da bfcache'e girerken (veya activateAfter süresi dolunca) son kaydı gönderiyor:

// Kullanıcının kitap sayfasında ne kadar kaldığını sayfa kapanırken bir kez gönderir
export function trackReading(isbn) {
  const started = Date.now();
  let pending = null;
  const payload = () => JSON.stringify({ type: "reading", isbn, seconds: Math.round((Date.now() - started) / 1000) });

  if ("fetchLater" in window) {
    // şimdi kaydet, veri değiştikçe yenisiyle değiştir; tarayıcı sonuncuyu gönderir
    const update = () => {
      pending?.abort();
      pending = new AbortController();
      fetchLater("/api/events", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: payload(),
        signal: pending.signal,
      });
    };
    update();
    setInterval(update, 1000);
    return "fetchLater";
  }

  // fallback: sayfa gizlenince gönder (pagehide/unload mobilde güvenilir değil)
  document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden") {
      fetch("/api/events", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: payload(),
        keepalive: true,
      });
    }
  });
  return "keepalive";
}

Chromium'da sayfayı 2,5 saniye sonra kapattım; WebKit'te fetchLater olmadığı için fallback çalıştı:

[chromium] => "function"
[chromium] => "fetchLater"
[api] #370 POST /api/events
[api] #370 204 event 53 bytes application/json {"type":"reading","isbn":"9780441172719","seconds":2}
[webkit] => "undefined"
[webkit] => "keepalive"
[webkit] visibilitychange 2562
[api] #389 POST /api/events
[api] #389 204 event 53 bytes application/json {"type":"reading","isbn":"9780441172719","seconds":3}
[api] #390 POST /api/events
[api] #390 204 event 53 bytes application/json {"type":"reading","isbn":"9780441172719","seconds":5}

Chromium saniyede bir yenilenen kayıtlardan yalnız sonuncusunu bir kez gönderdi. WebKit'te sayfa önce gizlendi (3. saniye), sonra Playwright tarayıcıyı kapatırken bir kez daha visibilitychange geldi ve ikinci bir event gitti. Sayfa gerçek hayatta da birçok kez gizlenip görünür olabilir; sunucu bu event'leri aynı oturum için "son gelen geçerli" mantığıyla işlemeli. fetchLater MDN verisine göre yalnız Chrome ve Edge 135+'ta var ve experimental; Firefox ve Safari'de yok. Kotası da ayrı: sayfa başına toplam 640 KiB, tek bir origin'e aynı anda 64 KiB; aşılırsa QuotaExceededError fırlatıyor. Güvenilir olmayan (HTTPS olmayan) URL'lere TypeError veriyor; localhost güvenilir sayıldığı için örnekte sorun çıkmadı.

Angular 22: HttpClient Artık Fetch Kullanıyor

Angular serisinin "Angular 22: Varsayılan OnPush, Stable Signal Forms ve Angular 12 ile Proje Farkı" yazısında gördüğümüz gibi, Angular 22'de HttpClient istekleri varsayılan olarak Fetch API ile gönderiyor; withFetch() deprecated oldu, XMLHttpRequest isteyen withXhr() ekliyor. Playwright'ta isteklerin türü bunu doğruluyor: httpResource ile yüklenen katalog fetch olarak görünüyor. Fetch'in eksik olduğu tek yer upload progress. Kapak yükleme ekranında reportUploadProgress isteyip withXhr() eklemeyince istek hiç gitmiyor:

[chromium] console.error: ERROR | HttpErrorResponse: status 0; error = Error: NG02824: The FetchBackend does not support upload progress reporting. Please use `withXhr()` on your `provideHttpClient()` configuration if you want to report upload progress.

Bütün uygulamayı XHR'a döndürmek gerekmiyor. provideHttpClient(withXhr())'ı yalnız o route'un providers'ına koyunca, route'un injector'ı kendi HttpClient'ını alıyor ve uygulamanın geri kalanı Fetch'te kalıyor:

import { provideHttpClient, withXhr } from '@angular/common/http';
import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', loadComponent: () => import('./catalog/catalog-page').then((m) => m.CatalogPage) },
  {
    path: 'reports/orders',
    loadComponent: () => import('./reports/order-report-page').then((m) => m.OrderReportPage),
  },
  {
    path: 'admin/covers',
    loadComponent: () => import('./admin/cover-upload-page').then((m) => m.CoverUploadPage),
    // upload progress için yalnız bu route XMLHttpRequest kullanır; uygulamanın geri kalanı Fetch'te kalır
    providers: [provideHttpClient(withXhr())],
  },
];
export class CoverUploadPage {
  private readonly http = inject(HttpClient);
  protected readonly progress = signal('');

  upload(file: File | undefined) {
    if (!file) return;
    this.http
      .post<CoverResult>('/api/covers/9780441172719', file, {
        observe: 'events',
        reportUploadProgress: true,
      })
      .subscribe((event) => {
        if (event.type === HttpEventType.UploadProgress) {
          const percent = Math.round((100 * event.loaded) / (event.total ?? file.size));
          this.progress.set(`${percent}%`);
          console.log(`upload ${event.loaded} of ${event.total}`);
        } else if (event.type === HttpEventType.Response) {
          this.progress.set(`done: ${event.body?.bytes} bytes`);
          console.log(`server received ${event.body?.bytes} bytes`);
        }
      });
  }
}

Upload'ı localhost'ta izlemek için Chromium'da upload hızını CDP (Chrome DevTools Protocol; tarayıcıyı dışarıdan yöneten protokol) üzerinden 1 MB/s'ye düşürdüm (DevTools'taki network throttling'in aynısı); 3 MB'lık dosya için 29 UploadProgress event'i geldi:

request xhr POST /api/covers/9780441172719
upload 114688 of 3000000
upload 212992 of 3000000
upload 327680 of 3000000
...
upload 3000000 of 3000000
server received 3000000 bytes

Bir route'a özel provideHttpClient çağrısı, üst seviyede tanımladığın interceptor'ları varsayılan olarak devralmaz; auth interceptor'ın varsa ya aynı withInterceptors'ı burada da ver ya da withRequestsMadeViaParent() kullan. withRequestsMadeViaParent() ise withXhr() ile aynı çağrıda kullanılamıyor; Angular bunu bir configuration hatasıyla reddediyor.

Rapor tarafında ters bir karar verdim. FetchBackend gövdeyi stream olarak okuyor ama bütün chunk'ları biriktirip sonunda tek parça hâlinde parse ediyor; reportDownloadProgress ile gelen partialText da o ana kadarki metnin tamamı. Büyük bir CSV'yi satır satır işlemek için HttpClient yerine düz fetch kullanan küçük bir service yazdım ve onu resource()'a bağladım:

@Service()
export class OrderReportService {
  readonly rowsRead = signal(0);

  // HttpClient yerine düz fetch: HttpClient gövdenin tamamını bellekte tutardı
  async sum(abortSignal: AbortSignal): Promise<ReportSummary> {
    const signal = AbortSignal.any([abortSignal, AbortSignal.timeout(30_000)]);
    signal.addEventListener('abort', () => console.log(`report aborted: ${signal.reason.name}`), {
      once: true,
    });
    const response = await fetch('/api/reports/orders.csv?rows=20000', { signal });
    if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);

    let rows = 0;
    let total = 0;
    this.rowsRead.set(0);
    const lines = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(splitLines());
    for await (const line of lines) {
      if (line.startsWith('orderId')) continue;
      total += Number(line.split(',')[4]);
      if (++rows % 5000 === 0) this.rowsRead.set(rows);
    }
    this.rowsRead.set(rows);
    return { rows, total };
  }
}
export class OrderReportPage {
  private readonly reports = inject(OrderReportService);
  protected readonly run = signal(0);
  protected readonly rowsRead = this.reports.rowsRead;

  // run() değişince ya da sayfa yok edilince resource() önceki yüklemeyi abort eder
  protected readonly report = resource({
    params: () => this.run() || undefined,
    loader: ({ abortSignal }) => this.reports.sum(abortSignal),
  });

  constructor() {
    effect(() => console.log(`rows read: ${this.rowsRead()}, status: ${this.report.status()}`));
  }
}

resource() loader'a bir abortSignal veriyor ve parametre değiştiğinde ya da component yok edildiğinde onu abort ediyor. Service bu signal'ı kendi timeout'uyla AbortSignal.any ile birleştiriyor. Playwright'ta raporu bir kez toplayıp, ikinci çalıştırmanın ortasında katalog sayfasına geçtim:

rows read: 0, status: idle
request fetch GET /api/reports/orders.csv
rows read: 0, status: loading
rows read: 5000, status: loading
rows read: 10000, status: loading
rows read: 15000, status: loading
rows read: 20000, status: loading
rows read: 20000, status: resolved
report: 20000 rows, TRY13,666,390.00
request fetch GET /api/reports/orders.csv
rows read: 20000, status: loading
rows read: 0, status: loading
rows read: 5000, status: loading
report aborted: AbortError
[api] #90 GET /api/reports/orders.csv?rows=20000
[api] #90 200 csv 20000 rows
[api] #91 GET /api/reports/orders.csv?rows=20000
[api] #91 client aborted

Sayfadan çıkınca resource() signal'ı abort etti, stream'in okunması 5.000. satırda durdu ve sunucu log'u isteğin istemci tarafından kesildiğini gösterdi. Bu tercihin bir bedeli var: düz fetch HttpClient interceptor'larından geçmiyor. Auth header'ı, hata log'u ya da retry gibi işler bir interceptor'daysa bu service'te elle yapılmalı.

Üretimde Dikkat

Stream'li bir rapor endpoint'inin arkasında bir proxy varsa önce onun davranışına bak. Nginx gibi reverse proxy'ler response'u varsayılan olarak buffer'layabilir ve istemci ilk satırı yine en sonda görür; Web Server'lar ve Hosting serisinin "Nginx ile Angular Yayınlamak: SPA Fallback, Cache Header'ları, Compression ve /api Proxy" yazısındaki proxy ayarlarını bu gözle gözden geçir. Sıkıştırma da aynı etkiyi yapabilir; ilk byte'a kadar geçen süreyi gerçek ortamda ölç.

Timeout değerini ölçerek seç. Raporun p95 süresi 8 saniyeyse 5 saniyelik timeout her yirmi kullanıcıdan birinin raporunu keser. TimeoutError ve AbortError'ı ayrı ayrı say: birincisi sunucunun yavaşladığını, ikincisi kullanıcının vazgeçtiğini gösteriyor ve ikisi farklı aksiyon istiyor.

Sayfa kapanışında gönderilen event'ler kaybolabilir. sendBeacon false döndüğünde, fetchLater QuotaExceededError fırlattığında bunu bir sayaçta tut; analytics'in yüzde kaç eksik geldiğini ancak böyle bilirsin. Bu event'leri gönderen kodun auth cookie'sine ihtiyacı varsa credentials ve CORS ayarlarını da test et: keepalive istekleri sayfa kapandıktan sonra gittiği için hatalarını konsolda görme şansın yok.

Sık Düşülen Tuzaklar

  • Response gövdesini iki kez okumak. İkinci okuma TypeError fırlatır; gerekiyorsa okumadan önce clone() çağır.
  • Hata adına göre karar vermek. WebKit timeout'ta da AbortError veriyor; hangi signal'ın tetiklendiğini signal.reason'dan oku.
  • Request streaming'i feature detection'a güvenerek açmak. WebKit 27.2'de detection true döndü ama upload başarısız oldu; her zaman stream'siz bir fallback bırak.
  • Sayfa kapanışında 64 KiB'ın üstünde veri göndermek. sendBeacon sessizce false döner, keepalive fetch reject eder; veriyi küçült ya da sayfa açıkken parça parça gönder.
  • Kapanış verisini unload event'inde göndermek. Mobilde güvenilir değil ve bfcache'i engelliyor; visibilitychange ya da fetchLater kullan.
  • Upload progress için bütün uygulamayı withXhr()'a çevirmek. Yalnız o route'a ver; Fetch'e özgü keepalive, priority gibi option'lar XHR'da uyarı verip yok sayılır.

Küçük Bir Deneme

★☆☆ sumReport'a bir AbortSignal parametresi ekle ve rapor okunurken iptal edilince o ana kadar işlenen satır sayısını konsola yaz. Sunucu log'unda "client aborted" satırını gör.

★★☆ uploadCoverStream'i, request streaming desteklenmediğinde ya da istek TypeError ile düştüğünde aynı dosyayı düz File gövdesiyle tekrar gönderen bir fonksiyona çevir. Chromium'da HTTP/1.1 ve HTTP/2 sunucuya, WebKit'te HTTP/1.1 sunucuya karşı dene; üç durumda da sunucunun 3.000.000 byte aldığını doğrula.

★★★ trackReading'i bir Angular service'ine taşı: kitap detay sayfası açıldığında başlasın, route değiştiğinde (DestroyRef) o kitabın süresini kapatıp bir sonrakini başlatsın. fetchLater olan tarayıcıda tek bir bekleyen kayıt, olmayan tarayıcıda visibilitychange fallback'i kullansın ve sunucu aynı kitap için gelen ikinci event'i yok saysın.

Bu yazıda verileri ağdan almanın ve göndermenin yollarına baktık; hepsi sayfa açıkken ve ağ varken çalıştı. Bir sonraki yazıda verinin tarayıcıda nerede saklanacağına geçiyoruz: localStorage, IndexedDB, Cache API, OPFS (tarayıcının her siteye ayırdığı özel dosya sistemi) ve cookieStore arasında seçim yapacak, kotayı navigator.storage.estimate() ile ölçecek ve tarayıcının veriyi hangi durumda sildiğini göreceğiz.