JavaScript’s async/await syntax was supposed to save us from callback hell. Instead, it introduced a quieter, more insidious problem: code that looks correct but behaves unpredictably. You see await sitting there in the function body and assume everything pauses politely, line by line. Often it does not. Loops race ahead. Entire batches collapse because of a single failed request. Entry files sprout ugly async wrappers for no reason. If you have run into any of these, these three patterns will straighten them out.

Stop Using await Inside forEach

Here is a common mistake that looks harmless on the page:

const urls = ['/api/user', '/api/posts', '/api/comments'];

urls.forEach(async (url) => {
  const res = await fetch(url);
  const data = await res.json();
  console.log(data);
});

console.log('All done!');

Run this, and 'All done!' prints before a single response comes back. Why? forEach executes the callback for every element immediately. It does not wait for the promise inside each iteration. The async keyword turns each callback into a promise that forEach promptly ignores. Your loop finishes in microseconds; the network requests wander off on their own. If you need errors handled in order, or if you need to guarantee one request finishes before the next begins, this pattern silently breaks both guarantees.

Replace it with a for...of loop:

const urls = ['/api/user', '/api/posts', '/api/comments'];

for (const url of urls) {
  const res = await fetch(url);
  const data = await res.json();
  console.log(data);
}

console.log('All done!');

Now the loop genuinely halts at each await. The second request waits for the first. 'All done!' prints only after everything settles.

Use for...of when sequence matters—uploading files one at a time to respect rate limits, writing database rows in a specific order, or chaining API calls where the next request needs data from the previous response. If you actually want parallel execution, do not fumble around with forEach. Reach for Promise.all explicitly so your intent is visible to the next developer. But never mix await and forEach expecting synchronous behavior. It will not happen.

Reach for Promise.allSettled When Zero Cannot Be the Answer

Promise.all is semantically honest. Hand it an array of promises, and it returns an array of results. The catch is that the moment any single promise rejects, the whole thing rejects immediately. Every other pending promise is left to finish on its own, but you lose access to their results. In production, this all-or-nothing behavior stings.

Imagine your application fetches a dashboard’s widgets from four independent services: traffic analytics, revenue data, user feedback, and server health. The revenue API hits a brief timeout. Under Promise.all, your entire dashboard throws an error. The three healthy responses vanish into the void. The user sees a spinner, then a failure screen, because one quarter of the data misbehaved.

Promise.allSettled gives you a saner contract. It waits until every single promise finishes, whichever way it goes. The resolved value is an array of objects describing each outcome:

const requests = [
  fetch('/api/traffic'),
  fetch('/api/revenue'),
  fetch('/api/feedback'),
  fetch('/api/health')
];

const results = await Promise.allSettled(requests);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    renderWidget(index, result.value);
  } else {
    renderError(index, result.reason);
  }
});

No response gets discarded. You render what you can and isolate the failure. This pattern matters whenever you are dealing with unrelated operations—bulk notifications, third-party webhook dispatches, or importing records from multiple CSV streams. You still need centralized error tracking, but your application keeps its footing.

One practical note: allSettled returns the full set, so you still need to sift through the results and decide what "partial success" means for your feature. Do not treat the returned array as uniformly happy data. Check those status fields before you push anything into your state layer.

Declare Top-Level await and Kill the Wrapper IIFE

For years, if you wanted to await something at the root of a file, you wrapped it in an immediately invoked async function:

(async () => {
  const config = await loadConfig();
  startServer(config);
})();

This works, but it is noise. Top-level await, native in ES modules, lets you drop the ceremonial boilerplate:

const config = await loadConfig();
startServer(config);

Use this at the entry point of your application or in dedicated configuration modules where initialization must complete before anything else runs. Loading environment files, establishing a database connection pool, or fetching remote feature flags are all natural fits. Because top-level await blocks execution of the module graph—other files importing this one will wait for your promise to resolve—you get a guaranteed state. The rest of your codebase can import db and know the connection is already live.

İki önemli nokta var. İlk olarak, çalışma zamanınız (runtime) veya paketleyiciniz (bundler) ES modules desteklemelidir. Node.js'de bu, ya .mjs uzantılarını kullanmak ya da package.json dosyanızda "type": "module" ayarını yapmak anlamına gelir. İkinci olarak, modül düzeyinde beklemek her içe aktarıcıyı (importer) geciktirdiğinden, beklenen (awaited) işi odaklı tutun. Sıkça içe aktarılan bir yardımcı (utility) dosyasının en üstündeki ağır sıralı veri çekme (fetch) işlemleri, tüm uygulamanızın soğuk başlatma (cold start) süresini yavaşlatacaktır. Top-level await özelliğini, diğer modüllerin gerçekten bağımlı olduğu gerçek bootstrap görevleri için saklayın.

Bu Desenleri Benimsediğinizde Aslında Ne Değişir?

İlk kazanç öngörülebilirliktir. Bir for...of döngüsü okuduğunuzda, altındaki bloğun tam olarak ne zaman bittiğini bilirsiniz. Arka planda yarışan hayalet promise'ler veya hata yakalayıcılarınızdan (error handlers) kopan foreach geri çağırmaları (callbacks) yoktur. Kontrol akışınız, ekrandaki kodun yapısıyla eşleşir.

Sırada dayanıklılık (resilience) gelir. Promise.allSettled, her harici sistemin kusursuz kalmasını ummak yerine sizi kısmi başarısızlıklar üzerine düşünmeye zorlar. Üretim ortamındaki (production) yazılımlar ikili (binary) değildir. Bazı uç noktalar (endpoints) kararsız çalışacaktır. Bazı dosya okuma işlemleri yetki hatalarıyla karşılaşacaktır. Dağınık başarısızlıkların gerçekliğine göre tasarım yapmak, meşru verileri halının altına süpürmeden uygulamanızı ayakta tutar.

Netlik ise her şeyi birbirine bağlar. for...of, düz bir İngilizce ilerleme gibi okunur. allSettled, niyetini isminde belirtir. Top-level await, gizemli IIFE sarmalayıcılarını kaldırır; böylece giriş dosyalarınız sözdizimsel akrobatiklerle değil, iş mantığıyla (business logic) başlar. Dosyaya dokunan bir sonraki mühendis —bu altı ay sonraki siz veya teslim tarihi yaklaşan bir ekip arkadaşınız olsun— size teşekkür edecektir.

Gerçek Bir Çıkarım

async/await yapısını, mevcut kodun üzerine serpiştireceğiniz küresel bir çözüm olarak görmeyin. Mevcut projelerinizi bu üç spesifik anti-pattern açısından denetleyin. forEach blokları içindeki await ifadelerini arayın ve bunları for...of veya bilinçli bir Promise.all ile değiştirin. Harici servislerle iletişim kuran her Promise.all yapısını gözden geçirin ve tek bir başarısızlığın tüm operasyonu gerçekten sabote edip etmemesi gerektiğini sorun; eğer etmemesi gerekiyorsa, Promise.allSettled yapısına geçin ve karma sonuçları yönetin. Son olarak, ES modülü giriş noktalarınızdaki async IIFE'leri temizleyin ve bootstrap dizinizi doğrudan top-level await'in yönetmesine izin verin. Bunlar küçük mekanik değişikliklerdir, ancak birlikte kırılgan asenkron betikleri gerçekten güvenebileceğiniz kodlara dönüştürürler.