Promisify Callbacks
intermediateConvert error-first callback APIs into promise-returning functions, like Node's util.promisify.
Run an array of async task functions in series, in parallel, or as a race — the implementations, why 'parallel' is really concurrency on one thread, first-settle vs first-success, timeouts, and the concurrency traps interviewers probe.
Short answer: Given an array of task functions, you control how many run at once. Series = await each in a for…of loop (total time = the sum). Parallel = Promise.all(tasks.map(t => t())) (total ≈ the slowest). Race = Promise.race(tasks.map(t => t())) (settles on the first to settle). The recurring trick: tasks must be functions — a promise is already running, so you can't sequence what already started.
This is the concurrency-shape family, and it's one of the most common async interview questions. It's part of the broader Promises guide; the escalation is always bounded concurrency — the pool and batching.
Every version below takes tasks: (() => Promise<T>)[], not Promise<T>[]. If you accept already-running promises, "series" is fiction — everything is already in flight. Accepting functions is what lets you decide when each starts. This is the #1 planted bug across all three variants.
Run them strictly one after another; task 2 must not start until task 1 settles.
async function runInSeries(tasks) {
const results = [];
for (const task of tasks) {
results.push(await task()); // start next ONLY after previous settles
}
return results;
}await inside a for…of loop is series execution — the loop body can't continue until the promise settles. This is the one place the no-await-in-loop lint rule is wrong: the rule exists to catch accidental serialization of independent work; here serialization is the requirement. Knowing why the rule exists beats blindly obeying it.
Before async/await, the idiom folded the tasks into one promise chain — and interviewers still ask for it by name:
function runInSeries(tasks) {
const results = [];
return tasks
.reduce(
(chain, task) => chain.then(() => task()).then((r) => results.push(r)),
Promise.resolve()
)
.then(() => results);
}The accumulator is a promise; each fold step extends the chain p.then(t1).then(t2)…. reduce knows nothing about promises — it's just building a value that happens to be a chain. Watch the O(n²) trap: chain.then(acc => task().then(r => [...acc, r])) copies the whole array every step; push keeps it O(n) — harmless at n=5, real at n=10,000.
When series is actually correct: each step needs the previous result (auth → profile → permissions), ordered side effects matter (migrations, ordered writes), or a fragile downstream needs one-at-a-time (series is a pool with limit 1). Otherwise series is just slow — "why is this taking 6 seconds?" is the interviewer asking you to notice the tasks were independent.
Start them all, wait for all, resolve with results in input order.
function runInParallel(tasks) {
return Promise.all(tasks.map((task) => task()));
}The sentence the interviewer actually wants: JavaScript is single-threaded, so your code never runs in parallel — but the waiting does. fetch, timers, and disk I/O happen off-thread; "parallel tasks" means N I/O operations in flight while the one JS thread handles completions one at a time. That's concurrency, and it's why this gives near-Nx speedups for I/O-bound work and roughly zero for CPU-bound work (true CPU parallelism needs Web Workers). Note that tasks.map(t => t()) is what starts everything — Promise.all only aggregates, returning results in input order regardless of completion order. Total time ≈ the slowest task.
// PARALLEL — both timers overlap: total ≈ 300ms
const [a, b] = await Promise.all([delay(300, "A"), delay(200, "B")]);
// SEQUENTIAL BY ACCIDENT — the classic await bug: total ≈ 500ms
const a = await delay(300, "A"); // nothing else is even started yet
const b = await delay(200, "B");
// The fix that keeps await syntax: start first, await after
const pA = delay(300, "A");
const pB = delay(200, "B"); // both running now
const a = await pA;
const b = await pB; // total ≈ 300msPromise.all is all-or-nothing: the first rejection rejects the aggregate (while other tasks keep running — no cancellation). If partial results are acceptable, don't hand-roll catch → null:
// ❌ null poisons downstream code, error details lost to console
tasks.map((t) => t().catch((e) => { console.error(e); return null; }))
// ✅ per-task outcomes, nothing swallowed
const outcomes = await Promise.allSettled(tasks.map((t) => t()));allSettled exists precisely so utilities don't invent lossy error conventions. "All-or-nothing → all; partial tolerance → allSettled; the choice belongs to the caller" is the senior phrasing.
const delay = (ms, v) =>
new Promise((res) => setTimeout(() => res(v), ms));
async function run() {
const a = delay(300, "A");
const b = delay(200, "B");
console.log(await a, await b);
}
run();Start them all and settle with the first task that settles — success or failure, whichever comes first.
function runInRace(tasks) {
if (tasks.length === 0) {
return Promise.reject(new Error("runInRace: no tasks provided"));
}
return Promise.race(tasks.map((task) => task()));
}Raw Promise.race([]) pends forever — correct for the spec method, but for a utility an eternal hang is a worse contract than a loud error, so we reject explicitly. "I'm deviating from spec semantics on purpose, here's why" earns more credit than either behavior silently. (Full quirk table in Promise.race.)
A rejection can win the race. If the fastest task fails at 100ms, the race rejects at 100ms even though a success was coming at 300ms:
runInRace([
() => delay(100).then(() => { throw new Error("fast failure"); }),
() => delay(300, "slow success"),
]).catch((e) => e.message); // "fast failure" — the success never got its chanceIf failures should lose rather than win — redundant mirrors, cache-vs-network — the tool is Promise.any (first fulfillment; aggregate rejection only if all fail). Choosing between race and any based on the failure story is exactly what this screens for.
Racing N equivalent sources is rare; racing work against a deadline is everywhere:
const result = await runInRace([
() => fetchFromApi(query),
() => delay(3000).then(() => { throw new Error("Timed out after 3s"); }),
]);Two upgrades: clear the timer when the real task wins (don't leak it), and remember losing the race doesn't cancel the fetch — the request completes and is discarded. Deadline plus cancellation is AbortSignal.timeout(ms) / AbortController; hedged-request systems (DNS resolvers, CDN failover, speculative RPCs) abort the losers explicitly on first win.
| Shape | Starts | Settles | Total time | Reach for it when |
|---|---|---|---|---|
| Series | one after another | after the last | sum of durations | steps depend on each other / ordered side effects |
| Parallel | all at once | after all settle | ≈ slowest task | independent work, results all needed |
| Race | all at once | on the first to settle | ≈ fastest to settle | timeouts, whichever-wins, hedging |
Series is k = 1, parallel is k = ∞; the general answer to "run at most K at once" is the concurrency pool. Say that sentence and you've connected the whole family.
[]; race is a policy call (we reject; state your contract).all fail-fast and propagate; the collect-and-continue contract is allSettled or per-task .catch(e => ({ error: e })) — outcome objects, never silent nulls that later crash as Cannot read properties of null.for…of+await, it's caught; but tasks.map(t => t()) throws before Promise.all/race exists, escaping as a sync exception. Wrap untrusted tasks with Promise.resolve().then(task).runInParallel(tenThousandTasks) opens 10,000 connections; browsers cap ~6 per origin on HTTP/1.1 and servers rate-limit. The production answer is a pool. Naming the cap before being asked is the difference between knowing the API and having operated it.forEach(async t => await t()) — forEach ignores the returned promises; all tasks fire at once and nothing is awaited.[...acc, r] spreads in the reduce chain — quadratic.await in a loop for independent tasks — accidental series, the most common real-world async perf bug.clearTimeout — lingering timers keep Node processes alive and pile up under load.all cancels the losers — they don't; work continues and side effects (writes!) still happen.acc = await task(acc); compare pipe.k = n gives parallel back.abort() on first win/fail.await in a loop serialize, and how does one thread handle 100 completions?" — suspension and the microtask queue: the event loop.postMessage; promises coordinate, workers compute.Convert error-first callback APIs into promise-returning functions, like Node's util.promisify.
Process a large list of async tasks in fixed-size sequential batches to protect downstream services.
Cache in-flight and resolved requests to deduplicate API calls — with cache invalidation trade-offs.
Retry a failing async operation N times with backoff — a small function with big production implications.