Có một đống task async I/O — fetch API, đọc file, gọi service — và bạn không muốn bắn hết một lúc (rate limit, RAM, connection pool). Cách “tự nhiên” hay gặp: cắt list thành batch, mỗi batch Promise.all, xong batch mới chạy batch sau.

Nghe hợp lý. Trên thực tế, tổng thời gian mỗi batch gần như bằng task chậm nhất trong batch đó. Task nhanh xong sớm rồi… ngồi chờ. Mình từng đổi hướng: giữ concurrency cố định, job nào rảnh thì nhặt task tiếp ngay.

Demo chạy được trên browser (một file HTML, không cần build): Batch vs Pool — mở và bấm “Chạy so sánh”. Có thể tải file về / mở trực tiếp; task là setTimeout giả lập I/O, có thanh tiến trình từng job.

Batch + Promise.all đang “trả giá” gì?

Giả sử concurrency = 3, có 6 task với thời gian (giả sử, ms):

A 100 · B 100 · C 900
D 100 · E 100 · F 100

Chạy theo batch 3:

Batch 1: A, B, C  → chờ max = 900
Batch 2: D, E, F  → chờ max = 100
Tổng ≈ 1000

A và B xong từ ms 100 nhưng slot của chúng “đóng băng” đến khi C xong. D, E, F phải xếp hàng dù lúc đó đã có 2 worker rảnh.

Công thức thô: mỗi batch tốn khoảng max(duration trong batch). Tổng ≈ tổng các max đó. Task lệch thời gian càng mạnh, lãng phí càng to.

Hướng pool: slot rảnh → lấy job tiếp

Ý tưởng đơn giản: luôn duy trì tối đa N task đang chạy. Task nào settle (resolve/reject) trước thì ngay lập tức start task tiếp theo trong hàng đợi — không đợi cả nhóm.

Cùng 6 task trên, concurrency 3:

t=0     chạy A, B, C
t=100   A, B xong → nhặt D, E ngay (C vẫn chạy)
t=200   D, E xong → nhặt F
t=900   C xong
Tổng ≈ 900  (thay vì ~1000; lệch lớn hơn khi list dài)

Không còn “hàng rào” giữa các batch. Worker không idle chỉ vì đồng nghiệp chậm.

Sketch code

Batch (dễ viết, dễ hiểu — và dễ phí thời gian):

async function mapInBatches(items, limit, fn) {
  const out = [];
  for (let i = 0; i < items.length; i += limit) {
    const slice = items.slice(i, i + limit);
    const part = await Promise.all(slice.map(fn));
    out.push(...part);
  }
  return out;
}

Pool — một hàng đợi, N “worker” ảo:

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

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

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

// ví dụ
await mapPool(urls, 5, (url) => fetch(url).then((r) => r.json()));

Vài điểm nhỏ:

  • next++ trên single-thread JS là đủ an toàn giữa các async worker (không có preemption giữa các microtask của cùng một turn kiểu race trên counter như multithread thật).
  • Muốn dừng sớm khi một task fail: bọc Promise.all như trên (fail-fast), hoặc tự gom lỗi / dùng pattern kiểu Promise.allSettled tùy nhu cầu.
  • Thư viện quen thuộc cùng ý tưởng: p-limit, p-map, queue trong Bull/BullMQ phía server… — bản chất vẫn là semaphore + lấy job khi rảnh.

Khi nào batch vẫn ổn?

Không phải lúc nào cũng phải pool:

  • Task gần như cùng thời lượng (đều ~200ms) — chênh lệch batch vs pool nhỏ.
  • Batch gắn với logic domain: “mỗi trang API 50 item”, “mỗi file chunk” — ranh giới batch là requirement, không chỉ throttle.
  • Code path throw-away, list ngắn, đọc dễ quan trọng hơn vài trăm ms.

Còn khi I/O lệch nhau mạnh (timeout, retry, endpoint chậm lác đác) và list dài — pool thường “cảm giác nhanh hơn” rõ, dù cùng concurrency limit.

Một câu để nhớ

Batch + Promise.all: tổng ≈ cộng các max mỗi lô — task nhanh trả giá cho task chậm cùng batch.

Pool: giữ N slot; slot nào xong lấy job tiếp — concurrency thật sự được “lấp đầy” theo thời gian, không theo hàng rào batch.

Đổi hướng không phải tối ưu huyền ảo: chỉ là đừng để worker rảnh ngồi nhìn đồng hồ vì mình vô tình xếp chúng thành từng đoàn phải về đích cùng lúc.