Run Async Tasks in Parallel
Start every task now, wait with Promise.all, and talk about the wall-clock vs series. Error handling and why unbounded parallel is a production bug.
- javascript
- promises
- async
- interview
A dashboard mounts and fires one fetch per widget. Twenty cards, twenty requests, one await Promise.all. On a good day the page paints after the slowest call. On a bad day the browser opens twenty connections, the API returns 429, Promise.all rejects, and the whole shell goes blank because ads failed.
Run async tasks in parallel when the work is independent and you care about wall clock, not start order. The interview is not “do you know Promise.all.” It is whether you start together on purpose, join results in input order, and notice that unbounded fan-out is a load test against yourself.
Series is the other pole: one in flight, sum of durations. If you need a cap between 1 and n, that is mapLimit, not a clever all.
- Parallel means invoke every thunk before you wait. Joining is a second step.
Promise.allpreserves result order. It does not preserve settlement order.- First rejection fails the join. The other tasks keep running unless you abort them.
- Wall clock tracks the slowest success path, not the sum, and not the first failure’s siblings.
- Unbounded
nis a production bug: connection limits, rate limits, memory.
Start together, join once, bound the fan-out
A parallel runner calls every task now, holds the promises, and fulfills with an array that matches the input index. The join is Promise.all (or an equivalent you wrote). Starting is tasks.map((t) => t()). Those two lines are the whole trick.
function asyncParallel(tasks) {
return Promise.all(
tasks.map((task) => Promise.resolve().then(() => task())),
);
}
const delay =
(ms, value) =>
() =>
new Promise((resolve) => setTimeout(() => resolve(value), ms));
await asyncParallel([delay(30, 'a'), delay(10, 'b')]);
// ['a', 'b'] after ~30ms
Promise.resolve().then(() => task()) wraps thenables and turns a synchronous throw into a rejection so one bad thunk does not skip Promise.all’s handler setup.
| Strategy | When a task starts | Wall clock (all fulfill) | Peak in flight |
|---|---|---|---|
| Series | after the previous fulfills | sum of durations | 1 |
| Unbounded parallel | immediately, all of them | max of durations | n |
| mapLimit | as slots free | between sum and max | k |
Order of the array is input order. Task b can finish first; index 1 still holds b’s value. Interviewers watch for people who push in completion order and scramble the UI.
Fail-fast is not cancel
Promise.all rejects on the first rejection. The other promises are not aborted. A 50ms failure next to a 5s fetch still leaves the fetch in flight. If that request mutates a row, you have a ghost write after the caller already catched.
To actually stop work, pass one AbortController into every thunk and abort() in the reject path. Ignoring the result (let cancelled = true) only protects your state, not the network.
async function asyncParallelCancellable(tasks, signal) {
const run = (task) =>
Promise.resolve().then(() => {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
return task(signal);
});
return Promise.all(tasks.map(run));
}
When all is the wrong join
Independent widgets that may fail in isolation want Promise.allSettled: one result per input, never a rejection for input failures. First-success across mirrors wants Promise.any. First settlement, including a fast error, wants Promise.race and is usually the wrong dashboard policy. Drill those on promise combinators. This page stays on “start all, join all, require success.”
Empty tasks: Promise.all([]) fulfills with []. Don’t special-case it unless you enjoy dead code.
Unbounded parallel is a production bug
Ten independent reads: all is fine. Ten thousand image URLs, or a recursive crawl that maps all at every directory: you will hit browser connection caps, server rate limits, and a memory graph of pending promises.
There is no Promise.all(limit) in the language. Chopping the array into slices of k and await Promise.all(slice) looks like a cap, but each batch waits for its slowest member before the next batch starts. That under-uses the cap the moment one item is slow. A worker pool fills a free slot immediately. Write the pool on the mapLimit page; don’t fake it here.
// Starts 10_000 fetches. Don't ship this.
await Promise.all(urls.map((url) => fetch(url)));
If the interviewer asks “how would you fetch these URLs,” say the cap out loud before you type all. k = 4 or k = 6 is a reasonable browser default; the number is a product choice, the pool is the algorithm.
Footguns of parallel execution
map+asynclooks sequential.await Promise.all(items.map(async (x) => f(x)))starts everyfduringmap. That is parallel. A barefor...ofwithawaitis series. Pick on purpose.- Push on settle. Results must land at the original index, not
results.push(value)from whichever finishes first. - HTTP
ok.fetchfulfills on 404. Parallel join offetchcalls can “succeed” with four error bodies. Throw on!res.okinside the thunk if success means 2xx. See Fetch API fundamentals. - Shared mutable state. Twenty tasks incrementing
cache[id]without coordination is a race. Parallel is for independent work. - Sparse arrays.
mapskips holes, so a hole never becomes a promise and the output stays sparse. Denseforoverlengthif you need a slot per index.
Interview out loud
“Independent work: I call every thunk immediately and join with
Promise.all, which keeps result order and rejects on the first failure. That does not cancel the others; I pass an AbortSignal if I need the network to stop. Wall clock is the max duration, unlike series, which sums. I will notPromise.allan unbounded list in production; I cap with a worker pool. Empty input fulfills with an empty array. If partial failure is allowed, I switch toallSettledand say so.”
Further reading
Related on this site
- Run async tasks in series
- mapLimit: async map with a concurrency cap
- Promise.all polyfill
- Promise combinators practice
- Promises
- Fetch API fundamentals
- AbortController
Related guides
- Run Async Tasks in SeriesExecute async functions one after another, collect results in order, and stop on first rejection. Reduce vs for-await, and when series is the point.
- Retry a Promise N TimesRetry an async function with a bounded count, delay, and a shouldRetry predicate. Do not retry 4xx you caused, and never hide the last error.
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Async Filter FunctionFilter an array with an async predicate: wait for every predicate, keep input order, emit a dense array, and decide fail-fast vs allSettled.
- Cancelable Async TasksWrap work so a caller can abort: AbortController, ignored stale results, and the difference between cancelling IO and ignoring a resolved promise.