Mert Özen Her Satırda Daha İleri
Frontend JavaScript İleri — #05

JavaScript İleri Asenkron Desenler: Promise.all Ailesi, İptal, Retry ve Timeout

Mert Özen 3 Kas 2026 9 dk 4 görüntülenme
JavaScript İleri Asenkron Desenler: Promise.all Ailesi, İptal, Retry ve Timeout

Promise.all, allSettled, any ve race farkını, ES2024'ün Promise.withResolvers'ını, ES2025'in Promise.try'ını, kendi fonksiyonunda AbortSignal desteğini, retry, timeout ve eşzamanlılık sınırlamayı kodla görüyoruz.

Sürüm notu: Bu yazıdaki kodlar ES2026 ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir.

Önce Tahmin Et

Üç promise var: biri 100 milisaniyede başarıyla biter, biri anında reddedilir, biri 300 milisaniyede başarıyla biter. Aynı üçlüyü iki farklı birleştiriciye veriyoruz. Konsola ne yazılır ve hangi sırayla?

const fast = new Promise((resolve) => setTimeout(() => resolve("fast"), 100));
const broken = Promise.reject(new Error("broken"));
const slow = new Promise((resolve) => setTimeout(() => resolve("slow"), 300));

Promise.any([broken, fast, slow]).then((value) => console.log("any:", value));

Promise.race([broken, fast, slow]).then(
  (value) => console.log("race:", value),
  (error) => console.log("race error:", error.message)
);

Okumaya devam etmeden önce bir tahmin yap. İpucu: any ve race isim olarak birbirine çok benziyor ama "kazanan" kelimesini farklı tanımlıyorlar.

Dört Birleştirici, Dört Farklı Soru

Daha önce Promise.all ve Promise.race'e kısaca bakmıştık. Aslında dört birleştirici var ve her biri farklı bir soruya cevap verir. Bunu bir proje teslimi gibi düşün. Promise.all "herkes teslim etti mi?" diye sorar; biri bile başarısız olursa hemen reddeder. Promise.allSettled "herkes bir sonuçla döndü mü?" diye sorar; başarı ya da hata fark etmez, hepsini bekler ve bir rapor verir. Promise.any "en az biri başardı mı?" diye sorar; ilk başarılıyı alır, hataları ancak hepsi başarısız olursa önemser. Promise.race ise sadece "ilk kim döndü?" diye sorar; başarı da olsa hata da olsa ilk sonuç kazanır.

Şimdi baştaki soruya dönebiliriz. Önce race error: broken yazılır, yaklaşık 100 milisaniye sonra da any: fast. broken zaten reddedilmiş bir promise olduğu için race'te ilk sonuçlanan odur; bir önceki yazıdan bildiğimiz gibi sonuç bir microtask ile, zamanlayıcıların hepsinden önce gelir. any ise reddedilen promise'i görmezden gelir ve ilk başarılı olanı, yani fast'i bekler.

Promise.allSettled ES2020 ile, Promise.any ES2021 ile geldi. allSettled'ın çıktısı, birden fazla isteğin hangisinin başarısız olduğunu görmek için idealdir:

const delay = (ms, value) => new Promise((resolve) => setTimeout(resolve, ms, value));

const results = await Promise.allSettled([
  delay(50, "profile"),
  Promise.reject(new Error("notification service is down")),
  delay(80, "messages")
]);

for (const result of results) {
  if (result.status === "fulfilled") console.log("ok:", result.value);
  else console.log("error:", result.reason.message);
}

Bu örnekte await'i bir fonksiyonun dışında kullandık. Konsol buna izin verir; bir HTML dosyasında aynısını yapmak için script'in type="module" olması gerekir. Promise.any'de bütün promise'ler reddedilirse sonuç bir AggregateError olur ve tek tek hatalar onun errors array'inde durur.

Bir şeyi net söyleyelim: bu birleştiricilerin hiçbiri kaybedenleri durdurmaz. Promise.all ilk hatada reddettiğinde diğer istekler arka planda çalışmaya devam eder, sadece sonuçlarıyla artık kimse ilgilenmez. Gerçekten durdurmak istiyorsan iptal mekanizması gerekir; ona birazdan geleceğiz.

Promise.withResolvers: Promise'i Dışarıdan Kontrol Etmek

Bazen bir promise'i oluşturduğun yer ile onu çözeceğin yer farklıdır. Eskiden bunun için şöyle bir kalıp yazardık: dışarıda iki let tanımla, new Promise'in içinde resolve ve reject'i bu değişkenlere ata. ES2024 ile gelen Promise.withResolvers bu kalıbı tek satıra indirir:

function nextClick(element) {
  const { promise, resolve } = Promise.withResolvers();
  element.addEventListener("click", resolve, { once: true });
  return promise;
}

console.log("Click anywhere on the page...");
const event = await nextClick(document.body);
console.log("clicked:", event.clientX, event.clientY);

Bunu konsola yapıştır, sonra sayfanın içinde bir yere tıkla. resolve fonksiyonunu doğrudan event listener olarak verdik; ilk tıklamada event object'iyle çağrılır ve promise çözülür. Bir event'i, bir kullanıcı onayını ya da bir mesajın gelişini promise'e çevirmek istediğinde bu desen çok işine yarar.

Promise.try: Senkron Hataları da Yakalamak

Elinde bazen senkron dönen, bazen promise döndüren, bazen de daha başlamadan hata fırlatan bir fonksiyon olabilir. Onu promise zincirine sokmak sandığından zordur:

function loadConfig(text) {
  if (!text) throw new Error("config is empty");
  return JSON.parse(text);
}

// Promise.resolve(loadConfig("")) → hata zincire girmeden fırlar

Promise.try(loadConfig, "")
  .then((config) => console.log(config))
  .catch((error) => console.log("caught:", error.message));
// "caught: config is empty"

Promise.resolve(loadConfig("")) yazsaydın, loadConfig argüman hesaplanırken hata fırlatırdı ve catch'e hiç ulaşmazdı. ES2025 ile gelen Promise.try fonksiyonu kendisi çağırır; dönen değeri, dönen promise'i ya da fırlatılan hatayı tek bir promise'e sarar. Ek argümanları da fonksiyona iletir. Promise.resolve().then(fn) hilesinden farkı şudur: fn bir microtask sonra değil, hemen, senkron olarak çalışır. Bütün güncel tarayıcılar destekliyor; eski sürümleri de hedefliyorsan bir kontrol koymayı unutma.

İptal: Kendi Fonksiyonunda AbortSignal Desteklemek

Daha önce fetch isteklerini AbortController ile iptal etmeyi gördük. Bu mekanizma fetch'e özel değil; kendi asenkron fonksiyonlarında da aynı sözleşmeyi uygulayabilirsin. Kural basit: fonksiyon bir signal kabul eder, başlamadan önce iptal edilmiş mi diye bakar, iptal gelirse işini bırakıp signal.reason ile reddeder ve iş bitince listener'ını temizler.

function wait(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    signal?.throwIfAborted();

    const onAbort = () => {
      clearTimeout(timerId);
      reject(signal.reason);
    };

    const timerId = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }, ms);

    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

const controller = new AbortController();

wait(5000, { signal: controller.signal })
  .then(() => console.log("done"))
  .catch((error) => console.log("aborted:", error.name)); // "aborted: AbortError"

controller.abort();

throwIfAborted, sinyal zaten iptal edilmişse reason'ı fırlatır; promise'in içinde olduğumuz için bu fırlatma otomatik olarak bir reddetmeye dönüşür. abort()'u argümansız çağırdığımızda tarayıcı reason olarak adı AbortError olan bir DOMException üretir. Fonksiyonun içinde fetch gibi başka bir iptal edilebilir iş varsa aynı signal'i ona da aktarırsın; böylece tek bir abort() çağrısı bütün zinciri durdurur.

Timeout: Bir İşe Süre Sınırı Koymak

İlk akla gelen çözüm, işi bir zamanlayıcıyla yarıştırmaktır:

function withTimeout(promise, ms) {
  let timerId;
  const timeout = new Promise((_, reject) => {
    timerId = setTimeout(() => reject(new Error(`did not finish within ${ms} ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timerId));
}

withTimeout(wait(2000), 500).catch((error) => console.log(error.message));
// "did not finish within 500 ms"

Çalışıyor, ama bir eksiği var: süre dolduğunda sen sonucu beklemeyi bırakırsın, iş ise devam eder. Bir fetch isteği arka planda inmeye devam eder. Asıl doğru yol, süre sınırını bir iptal sinyali olarak işin kendisine vermektir. Daha önce gördüğümüz AbortSignal.timeout(ms) tam bunu yapar. Hem kullanıcının iptal butonunu hem de süre sınırını birlikte desteklemek istiyorsan AbortSignal.any ile iki sinyali birleştirirsin; hangisi önce tetiklenirse birleşik sinyal de iptal olur:

const userController = new AbortController();
const signal = AbortSignal.any([userController.signal, AbortSignal.timeout(500)]);

wait(2000, { signal }).catch((error) => console.log(error.name));
// "TimeoutError"

Zaman aşımında reason, adı TimeoutError olan bir DOMException'dır. Böylece kullanıcının iptal etmesiyle sürenin dolmasını birbirinden ayırabilirsin.

Retry: Tekrar Dene, Ama Akıllıca

Ağ istekleri geçici sebeplerle başarısız olabilir. Hemen ve sürekli tekrar denemek ise zaten zorlanan bir sunucuya yük bindirir. Doğru desen üstel geri çekilmedir (exponential backoff): her denemeden sonra bekleme süresini ikiye katla, üstüne biraz rastgelelik (jitter) ekle ki binlerce istemci aynı anda tekrar denemesin.

async function retry(task, { retries = 3, baseDelay = 200, signal } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await task({ attempt, signal });
    } catch (error) {
      if (attempt >= retries || signal?.aborted) throw error;
      const backoff = baseDelay * 2 ** attempt;
      const jitter = Math.random() * baseDelay;
      await wait(backoff + jitter, { signal });
    }
  }
}

let calls = 0;
const flaky = async () => {
  calls += 1;
  if (calls < 3) throw new Error(`attempt ${calls} failed`);
  return "finally worked";
};

console.log(await retry(flaky), calls); // "finally worked" 3

Bu kod, yukarıdaki wait fonksiyonunu kullanıyor; önce onu yapıştırmış olman gerekiyor. return await satırındaki await süs değil. Onu silersen task'ın döndürdüğü promise reddedildiğinde hata try bloğunun dışında gerçekleşir, catch onu hiç görmez ve tekrar deneme olmaz. Bir de neyi tekrar denediğine dikkat et: bağlantı hatası ya da 503 gibi geçici bir durum denemeye değer, 404 ya da 400 değmez; aynı istek aynı cevabı alacaktır. Ödeme gibi tekrarlandığında iki kez etki eden işlemleri ise sunucu buna hazır değilse hiç otomatik tekrarlama.

Eşzamanlılık Sınırlama: Aynı Anda En Fazla N İş

Elinde 200 resim adresi var ve hepsini indirmek istiyorsun. Promise.all(urls.map(fetch)) 200 isteği aynı anda başlatır. Tarayıcı aynı sunucuya açtığı bağlantı sayısını zaten sınırlar, ama yine de 200 isteğin hepsi bellekte bekler, sunucu bir anda yüklenir ve kullanıcının asıl önemli isteği queue'nun sonuna düşer. Çözüm, aynı anda çalışan iş sayısını sınırlayan küçük bir havuz:

async function mapWithLimit(items, limit, worker) {
  const results = new Array(items.length);
  let next = 0;

  async function runner() {
    while (next < items.length) {
      const index = next++;
      results[index] = await worker(items[index], index);
    }
  }

  const runners = Array.from({ length: Math.min(limit, items.length) }, runner);
  await Promise.all(runners);
  return results;
}

let active = 0;
const output = await mapWithLimit([1, 2, 3, 4, 5, 6, 7], 2, async (id) => {
  active += 1;
  console.log(`start ${id} (running: ${active})`);
  await wait(300);
  active -= 1;
  return id * 10;
});

console.log(output); // [10, 20, 30, 40, 50, 60, 70]

Fikir şu: limit kadar runner başlatırsın ve her biri bir iş bitirdiğinde listeden sıradakini alır. Çıktıda running değerinin hiçbir zaman 2'yi geçmediğini göreceksin. Sonuçlar, işlerin bitiş sırasına göre değil, listedeki sıraya göre yerleşir, çünkü her sonuç kendi index'ine yazılır.

Peki iki runner aynı next değerini kapamaz mı? Kapamaz. Bir önceki yazıda gördüğümüz gibi JavaScript tek thread'de çalışır ve bir kod parçası await'e gelene kadar kesintisiz devam eder. next < items.length kontrolü ile next++ arasında await olmadığı için başka bir runner araya giremez.

Sık Düşülen Tuzaklar

Promise'i havada bırakmak. Bir async fonksiyonu await etmeden ya da catch bağlamadan çağırırsan, reddedildiğinde hata kimseye ulaşmaz; tarayıcı sadece konsola bir "Uncaught (in promise)" yazar ve unhandledrejection event'i tetiklenir. Ya bekle ya da bilinçli olarak bir catch bağla.

Boş array'lerin davranışını unutmak. Promise.all([]) hemen boş bir array'le çözülür, Promise.any([]) hemen bir AggregateError ile reddedilir, Promise.race([]) ise hiçbir zaman sonuçlanmaz. Dinamik bir listeyi race'e veriyorsan liste boşken kodun sonsuza kadar bekleyebilir.

race tabanlı timeout'un işi durdurduğunu sanmak. Yukarıda gördük: süre dolar, iş devam eder. Gerçekten durdurmak için sinyal kullan.

Abort listener'larını temizlememek. Uzun ömürlü bir sinyale her çağrıda addEventListener yapıp iş bitince kaldırmazsan listener'lar birikir. wait fonksiyonunda zamanlayıcı dolduğunda removeEventListener çağırmamızın sebebi bu; bellek sızıntılarını ilerleyen yazılarda ayrıca konuşacağız.

allSettled'dan sonra hataları kontrol etmemek. allSettled asla reddetmez; bu, hata olmadığı anlamına gelmez. Her sonucun status property'sine bakmazsan hatalar sessizce yutulur.

Küçük Bir Deneme

★☆☆ Çalıştırmadan önce tahmin et: Promise.all([delay(100, "a"), Promise.reject(new Error("b")), delay(50, "c")]).then(console.log, (e) => console.log("error:", e.message)); Kaç milisaniye sonra ne yazılır? Aynı array'i allSettled ile dene ve çıktıyı karşılaştır.

★★☆ retry fonksiyonuna bir shouldRetry(error) seçeneği ekle. Varsayılan olarak her hatada tekrar denesin, ama sen bunu bir fetch sarmalayıcısıyla kullanırken yalnızca ağ hatalarında ve 5xx durum kodlarında tekrar denemesini sağla.

★★★ mapWithLimit'e signal desteği ekle: sinyal iptal edildiğinde yeni iş başlatılmasın, çalışan işlere de aynı sinyal iletilsin ve fonksiyon signal.reason ile reddetsin. Sonra bunu, beş saniye sonra kendiliğinden iptal olan bir AbortSignal.timeout ile 20 işlik bir listede dene.

Bu yazıda tek tek değerler üreten promise'leri birleştirdik. Bir sonraki yazıda farklı bir soruya geçiyoruz: değerleri bir seferde değil, ihtiyaç duyuldukça, tek tek üretmek. Iterator protokolü, Symbol.iterator, generator fonksiyonları, yield ve ES2025 ile gelen iterator helper'lar.