InterviewsVector
intermediateCommon5 min read · Updated Jul 18, 2026

Batch Async Tasks in JavaScript (and Where Batching Beats a Pool)

Implement fixed-size sequential batching for async tasks in JavaScript — with the task-function-vs-promise trap, batch vs pool trade-offs, and rate-limit realities.


The problem

Process a large list of async tasks in fixed-size batches: run K tasks, wait for the whole batch to finish, then start the next K. Resolve with all results in order.

Before writing anything, this question hides the single most important sentence in async interviewing:

A promise is already running. You cannot throttle promises — only the creation of promises.

If the input is urls.map((url) => fetch(url)), every request is already in flight before any batching code runs; "batching" that array merely awaits results in groups while the network melts. The input must be task functions() => fetch(url) — so your code controls when each starts. Interviewers plant this exact trap, and stating it unprompted is often the pass/fail moment. (The promise pool makes the same point; it applies to every throttling question.)

Implementation

async function batchTasks(tasks, batchSize) {
  if (batchSize < 1) throw new RangeError("batchSize must be >= 1");
  const results = [];
 
  for (let i = 0; i < tasks.length; i += batchSize) {
    const batch = tasks.slice(i, i + batchSize);
 
    // Start ONLY this batch's tasks, run them concurrently, wait for ALL.
    // Promise.resolve().then(task) routes sync throws into the promise.
    const settled = await Promise.allSettled(
      batch.map((task) => Promise.resolve().then(task))
    );
    results.push(...settled);
  }
 
  return results; // input order: batches are sequential, slices preserve order
}

Choices worth narrating:

  • allSettled, not all — with Promise.all, one failure in batch 1 abandons batches 2–N while batch 1's other tasks still run. For a bulk-processing tool, per-item outcomes (the reflect pattern) are almost always the right contract. If the interviewer wants fail-fast, it's a one-word change — say the trade-off out loud and ask.
  • Promise.resolve().then(task) — a task that throws synchronously still lands in that item's rejected slot instead of blowing up the loop.
  • The loop is the batching — no recursion, no counters; for + await reads exactly like the requirement. Compare the recursive runBatch formulations that interleave slicing arithmetic with state — same behavior, triple the surface for off-by-ones.

Usage

const tasks = urls.map((url) => () => fetch(url).then((r) => r.json()));
//                       ^^^^^^^ functions — nothing starts until its batch
 
const outcomes = await batchTasks(tasks, 5);
for (const [i, o] of outcomes.entries()) {
  if (o.status === "rejected") console.error(urls[i], o.reason);
}

Dry run (7 tasks, batchSize 3)

batch 1: start t0,t1,t2 → wait for all three   ← 3 in flight
batch 2: start t3,t4,t5 → wait                  ← 0 in flight between batches
batch 3: start t6       → wait
resolve with 7 settled results, input order

Batching vs pool — the comparison this question exists for

The last task in a batch gates the next batch: if t0 and t1 finish in 100ms but t2 takes 9s, two slots idle for 8.9s. A pool refills each slot the moment it frees:

BatchingPool
Max in flightKK
Slot refillwhen the whole batch endswhen any task ends
Utilizationidles on stragglerskeeps K busy throughout
Natural fitrate limits ("N requests per second"), checkpointed bulk jobslatency-bound work, mixed task durations
Complexitya for loopa worker/queue mechanism

So why does batching exist at all? Two honest answers: (1) rate-limit alignment — "30 requests/minute" maps to timed batches more directly than to a pool; (2) checkpointing — batch boundaries are natural places to commit progress, flush logs, or sleep. "Pool for throughput, batches for rate windows and checkpoints" is the summary sentence to leave the interviewer with.

For true rate limits, add the pause: after each batch, await sleep(windowMs) — and note that batch boundaries alone don't cap requests per second if tasks finish fast; a real limiter tracks timestamps (token bucket), which is a good escalation question to acknowledge.

Edge cases interviewers probe

  • Already-running promises as input — the trap; restate the task-function rule.
  • tasks.length % batchSize !== 0 — the final short batch; slice handles it, but say you've considered it.
  • Empty task list → resolves [] — falls out of the loop naturally; no special case needed (a sign the shape is right).
  • batchSize ≥ list length — degenerates to one parallel batch; ≤ 0 must be rejected loudly, not spin forever.
  • Failure policyallSettled vs all is a contract decision; whichever you pick, pick it visibly.
  • Cancellation between batches — the clean place to check an AbortSignal: finish the current batch, skip the rest — batch boundaries make graceful shutdown easy, another genuine advantage over pools.

Common mistakes

  • Batching promises instead of task functions (the headline bug — everything else is secondary).
  • Promise.all without acknowledging that later batches are skipped while current-batch tasks still complete in the background.
  • Index arithmetic (currentBatch * batchSize + index) scattered through nested callbacks — correct until the first edit; the flat loop avoids it entirely.
  • Calling this "throttling requests per second" when it only caps concurrency — a rate limiter needs timestamps.
  • Recursion without need — runBatch() self-calls re-implement what for already says.

Follow-up questions

  • "Convert this to a pool." — same task-function input, worker loop instead of batch loop: promise pool. The pair of implementations side by side is a common 45-minute arc.
  • "Add onProgress." — invoke onProgress(done, total) after each batch; trivially testable because batch boundaries are deterministic.
  • "Stream results instead of collecting." — yield per batch (async function* + yield* settled) — connects to iterator helpers.
  • "What if one task hangs forever?" — the batch never completes; wrap tasks with a race-based timeout or signal-based deadline.
  • "Real rate limiter?" — token bucket: allow a request when tokens remain, refill on a timer — the algorithm-design follow-up this question feeds into at senior loops.

  • Run async tasks one after another and understand why reduce-with-promises works.

  • Retry a failing async operation N times with backoff — a small function with big production implications.

  • Fire all tasks at once and gather results — plus what parallel really means on a single thread.