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.

Có hai lưu ý quan trọng. Thứ nhất, runtime hoặc bundler của bạn phải hỗ trợ ES modules. Trong Node.js, điều đó có nghĩa là bạn phải sử dụng phần mở rộng .mjs hoặc thiết lập "type": "module" trong package.json. Thứ hai, vì việc chờ đợi ở cấp độ module sẽ làm chậm mọi trình nhập (importer), hãy giữ cho các tác vụ được await thật tập trung. Các lệnh fetch tuần tự nặng nề ở đầu một file utility thường xuyên được import sẽ làm chậm quá trình khởi động lạnh (cold start) của toàn bộ ứng dụng. Hãy dành top-level await cho các tác vụ bootstrap thực sự mà các module khác thực sự phụ thuộc vào.

Những thay đổi thực sự khi bạn áp dụng các pattern này

Khả năng dự đoán là lợi ích đầu tiên. Khi bạn đọc một vòng lặp for...of, bạn biết chính xác khi nào khối lệnh bên dưới kết thúc. Không còn những promise "ma" chạy ngầm phía sau, không còn các callback foreach bị tách rời khỏi các trình xử lý lỗi của bạn. Luồng điều khiển (control flow) của bạn sẽ khớp với hình dáng của mã nguồn hiển thị trên màn hình.

Tiếp theo là khả năng phục hồi (resilience). Promise.allSettled buộc bạn phải nghĩ về các lỗi cục bộ (partial failure) thay vì hy vọng mọi hệ thống bên ngoài đều hoạt động hoàn hảo. Phần mềm chạy thực tế (production) không phải là nhị phân. Một số endpoint sẽ chập chờn. Một số thao tác đọc file sẽ gặp lỗi quyền truy cập. Thiết kế dựa trên thực tế về các lỗi rải rác giúp ứng dụng của bạn vẫn đứng vững mà không cần phải che giấu những dữ liệu hợp lệ.

Sự rõ ràng là yếu tố gắn kết tất cả. for...of đọc giống như một tiến trình tiếng Anh thông thường. allSettled thể hiện rõ mục đích ngay trong tên gọi của nó. top-level await loại bỏ các lớp bọc IIFE khó hiểu, giúp các file entry của bạn bắt đầu bằng logic nghiệp vụ thay vì các màn "nhào lộn" cú pháp. Kỹ sư tiếp theo chạm vào file này—dù đó là chính bạn sau sáu tháng nữa hay một đồng nghiệp đang chạy deadline—sẽ cảm ơn bạn.

Bài học thực tế

Đừng coi async/await như một giải pháp vạn năng để rắc lên mã nguồn hiện có. Hãy kiểm tra (audit) các dự án hiện tại của bạn để tìm ba anti-pattern cụ thể này. Tìm kiếm await bên trong các khối forEach và thay thế chúng bằng for...of hoặc một Promise.all có chủ đích. Xem xét lại mọi Promise.all có giao tiếp với các dịch vụ bên ngoài và tự hỏi liệu một lỗi duy nhất có thực sự làm hỏng toàn bộ hoạt động hay không; nếu không, hãy chuyển sang Promise.allSettled và xử lý các kết quả hỗn hợp. Cuối cùng, hãy loại bỏ các async IIFE khỏi các entry point của ES module và để top-level await trực tiếp xử lý trình tự bootstrap của bạn. Đây là những thay đổi nhỏ về mặt kỹ thuật, nhưng khi kết hợp lại, chúng biến các script bất đồng bộ mong manh thành mã nguồn mà bạn thực sự có thể tin tưởng.