Lập trình · 26/09/2026

JavaScript forEach với async: Vì sao await không chờ như bạn nghĩ?

Màn hình thông báo “Đã xử lý xong” nhưng các tác vụ vẫn đang chạy. Trong JavaScript, một nguyên nhân thường gặp là dùng forEach với callback async rồi tưởng rằng vòng lặp sẽ chờ tất cả hoàn thành. Cách sửa phụ thuộc vào việc bạn cần xử lý tuần tự hay đồng thời.

JavaScript forEach với async: Vì sao await không chờ như bạn nghĩ?

Màn hình thông báo “Đã xử lý xong” nhưng các tác vụ vẫn đang chạy. Trong JavaScript, một nguyên nhân thường gặp là dùng forEach với callback async rồi tưởng rằng vòng lặp sẽ chờ tất cả hoàn thành. Cách sửa phụ thuộc vào việc bạn cần xử lý tuần tự hay đồng thời.

1. Callback async không làm forEach biết chờ

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

async function demo() {
  [1, 2].forEach(async id => {
    await delay(20);
    console.log('Processed', id);
  });
  console.log('Finished');
}
demo().catch(console.error);

Trong ví dụ, Finished xuất hiện trước các dòng Processed. Theo tài liệu forEach của MDN, hàm không chờ Promise và trả undefined. Thêm await trước forEach không biến kết quả đó thành Promise đại diện cho toàn bộ công việc.

Đoạn demo chỉ dùng bộ hẹn giờ, không gửi request hay thay đổi dữ liệu thật. Khoảng 20 ms là tham số mô phỏng, không phải cam kết thời gian thực thi chính xác.

2. Cần thứ tự: for...of với await

async function runSequential(ids, processItem) {
  const results = [];
  for (const id of ids) {
    results.push(await processItem(id));
  }
  return results;
}

Giả định processItem trả Promise chỉ hoàn thành khi công việc tương ứng đã xong. Tác vụ kế tiếp chỉ bắt đầu sau khi tác vụ hiện tại thành công. Nếu một tác vụ ném lỗi hoặc reject, hàm dừng và trả Promise bị reject, trừ khi bạn chủ động bắt lỗi trong vòng lặp.

Đây là lựa chọn dễ hiểu khi tác vụ sau phụ thuộc kết quả hoặc tác động của tác vụ trước. Nó không tự tạo giao dịch: những ghi dữ liệu đã thành công trước lỗi vẫn có thể còn trên server.

3. Tác vụ độc lập: map kết hợp Promise.all

async function runConcurrent(ids, processItem) {
  return await Promise.all(
    ids.map(async id => processItem(id))
  );
}

Promise.all gom kết quả theo thứ tự đầu vào, không theo thứ tự hoàn thành. Nó reject khi một Promise đầu vào reject, nhưng không tự hủy các tác vụ còn lại đã chạy.

Trong mẫu này, map gọi processItem cho từng phần tử mà không đợi phần tử trước xong. Không áp dụng nguyên mẫu cho hàng nghìn request chỉ vì dữ liệu độc lập: cần giới hạn số tác vụ đồng thời phù hợp với hệ thống đích.

4. Người gọi cũng phải theo dõi Promise

async function main() {
  const results = await runSequential([1, 2], async id => {
    await delay(20);
    return { id, ok: true };
  });
  console.log('Finished', results);
}
main().catch(console.error);

Ví dụ này dùng lại delay ở phần đầu. Trong ứng dụng thật, chỉ hiển thị thành công sau await của luồng xử lý. Nếu bắt lỗi rồi chỉ ghi log và tiếp tục trả thành công, bạn lại tạo ra thông báo gây hiểu nhầm dù vòng lặp đã đúng.

Catch gắn vào demo ban đầu không tự nhận các lỗi từ Promise bị bỏ qua trong callback forEach. Muốn quản lý lỗi ở cấp nhóm tác vụ, cần trả hoặc chờ đúng chuỗi Promise thay vì chỉ đặt try/catch quanh việc khởi chạy.

5. Checklist trước khi dùng cho công việc thật

  • Xác định tác vụ có cần thứ tự hay không.
  • Hàm xử lý phải trả Promise gắn với thời điểm hoàn tất thực sự.
  • Thử một tác vụ chậm và một tác vụ lỗi.
  • Không báo thành công khi mới khởi chạy.
  • Giới hạn mức đồng thời và thiết kế retry riêng khi cần.
  • Với tác vụ ghi, làm rõ cách xử lý thành công một phần; await không phải rollback.

Chọn cấu trúc vòng lặp không chỉ để code ngắn hơn. Điều quan trọng là nơi gọi biết chính xác lúc nào công việc hoàn tất, lỗi đi đâu và những tác vụ nào vẫn có thể đang chạy.

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.