Understanding Promise.all(): failure, concurrency, and cancellation
I used to think Promise.all() worked like a team where one person quitting cancels the whole project. One promise rejects, the rest stop, and Promise.all() reports the failure. That is a clean mental model, and it is wrong in the one way that matters. When a promise in Promise.all() rejects, the others do not stop. They keep running to completion, and Promise.all() just stops listening. I learned this the slow way, by watching a request I thought was cancelled finish anyway, and wondering where the extra work came from.
failure: it rejects fast, and the rest keep going
Here is the rule, straight from MDN on Promise.all(): Promise.all() rejects immediately when any input promise rejects, and the rejection reason is the first promise that rejected. But rejecting the returned promise does not cancel the remaining operations. The other promises keep running, and their results are ignored. Subsequent rejections after the first are ignored too, so they do not even trigger unhandled rejection warnings.
Promise.all rejects at 150ms, the moment posts fails. The user and prefs requests keep running anyway, they finish at 300ms and 600ms, and nobody hears about them.Say you fire three requests at once, user at 300ms, posts at 150ms, and prefs at 600ms, and posts fails. Promise.all() rejects at 150ms, the moment posts fails. The user request still resolves at 300ms and prefs still resolves at 600ms, because nothing told them to stop. They did the work, the network carried it, and the results went nowhere. If those requests have side effects, like writing to a log or updating a cache, the side effects still happen. That is the part that surprises people, and it is the part worth remembering.
const user = fetch('/api/user'); // resolves at 300ms
const posts = fetch('/api/posts'); // rejects at 150ms
const prefs = fetch('/api/prefs'); // resolves at 600ms
try {
const [u, p, pr] = await Promise.all([user, posts, prefs]);
} catch (err) {
// Runs at 150ms, the moment posts rejects.
// user and prefs are still in flight.
// They resolve at 300ms and 600ms, and nobody hears about it.
console.error(err.message);
}concurrency: they all start at once
The reason Promise.all() is worth reaching for is that it runs everything concurrently. All three requests start at the same time, and the total wait is the slowest one, not the sum of all three. That is the point, and it is also the part people get wrong by accident. If you await each request one after another, you pay for all three in sequence, even though none of them depends on the others. Promise.all() fixes that by starting them together and waiting once.
One detail that trips people up: you have to pass promises, not functions. Promise.all([fetchUser, fetchPosts]) does nothing, because those are functions, not promises, and Promise.all() treats non-promises as already-resolved values. You have to call the functions first, Promise.all([fetchUser(), fetchPosts()]), so the work actually starts. I have made this mistake, and the symptom is confusing, because everything resolves instantly with the functions themselves as the values.
// Sequential: pays for all three, one after another.
// Total wait is 300 + 150 + 600 = 1050ms.
const user = await fetch('/api/user');
const posts = await fetch('/api/posts');
const prefs = await fetch('/api/prefs');
// Concurrent: all three start at once.
// Total wait is the slowest one, 600ms.
const [u, p, pr] = await Promise.all([
fetch('/api/user'),
fetch('/api/posts'),
fetch('/api/prefs'),
]);cancellation: there is none, so bring your own
Promise.all() has no cancellation. There is no way to tell it to stop the other promises when one fails, because promises themselves have no cancel method. If you actually need to cancel work, you bring your own mechanism, and the standard one is AbortController. You create a controller, pass its signal to fetch, and call abort() when you want the request to stop. The fetch rejects with an abort error, and the network request is actually cancelled, not just ignored.
const controller = new AbortController();
// Pass the signal to fetch. If abort() is called,
// the fetch rejects with an abort error.
const user = fetch('/api/user', { signal: controller.signal });
const posts = fetch('/api/posts', { signal: controller.signal });
try {
const [u, p] = await Promise.all([user, posts]);
} catch (err) {
// One failed, so cancel the other for real.
controller.abort();
}The other tool worth knowing is Promise.allSettled(), which waits for every promise to finish, whether it resolves or rejects, and gives you back an array of results with a status of fulfilled or rejected for each one. That is the right call when you want every result even if some fail, like loading a dashboard where one widget failing should not hide the rest. Promise.all() is the right call when you need all of them to succeed, and one failure means the result is not usable.
try it
The demo below runs three fake requests, user at 300ms, posts failing at 150ms, and prefs at 600ms, and shows what each combinator returns. Watch the log: posts rejects first, Promise.all() rejects right away, and the other two still finish.
Press run to see the timeline.
Promise.allNot run yet.
Promise.allSettledNot run yet.
What I took away from this is that Promise.all() is three behaviors wearing one name. It runs things concurrently, which is why you reach for it. It rejects on the first failure, which is why you have to think about what failure means for your code. And it cancels nothing, which is why cancellation is a separate tool you bring yourself. Once I stopped assuming the rejection stopped the work, the rest of it fell into place, and I started reaching for allSettled() whenever I wanted every result, and AbortController whenever I wanted a real stop.