N Tasks in Series
beginnerRun async tasks one after another and understand why reduce-with-promises works.
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.
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.)
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.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.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);
}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 orderThe 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:
| Batching | Pool | |
|---|---|---|
| Max in flight | K | K |
| Slot refill | when the whole batch ends | when any task ends |
| Utilization | idles on stragglers | keeps K busy throughout |
| Natural fit | rate limits ("N requests per second"), checkpointed bulk jobs | latency-bound work, mixed task durations |
| Complexity | a for loop | a 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.
tasks.length % batchSize !== 0 — the final short batch; slice handles it, but say you've considered it.[] — 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.allSettled vs all is a contract decision; whichever you pick, pick it visibly.Promise.all without acknowledging that later batches are skipped while current-batch tasks still complete in the background.currentBatch * batchSize + index) scattered through nested callbacks — correct until the first edit; the flat loop avoids it entirely.runBatch() self-calls re-implement what for already says.onProgress." — invoke onProgress(done, total) after each batch; trivially testable because batch boundaries are deterministic.async function* + yield* settled) — connects to iterator helpers.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.
Cancel fetch requests, add timeouts with AbortSignal.timeout, and write abortable async utilities.