Stop Stacking Awaits: Two-Stage Parallelism with Promise.all
Foreword
async/await is really pleasant to write — one await after another, asynchronous code reads like synchronous code.
But it falls apart when you have many interfaces: 5 interfaces awaited serially, and the user waits for the sum of those 5 time periods. Clearly, these 5 interfaces don't depend on each other, so why should they queue up?
The answer lies in Promise.all. Today, using a real, runnable piece of fetch dual-interface concurrent code, we'll string together the three states of a Promise, the "one fails, all fail" rule, and two-stage parallelism.
The Three States of a Promise: pending / fulfilled / rejected
As soon as new Promise executes, it immediately enters pending. It can then only proceed to one of two endpoints:
| State | Meaning | Trigger | Can it revert? |
|---|---|---|---|
| pending | Pending, no result yet | Just new'd | Can transition |
| fulfilled | Success | resolve() |
Cannot revert to pending |
| rejected | Failure | reject() |
Cannot revert to pending, nor become fulfilled |
Key rule: The state can only go from pending → fulfilled, or pending → rejected. Once settled, it cannot change. This is why a Promise is called a "promise" — it's either kept or broken, no take-backs.
In the console, the Promise {<pending>} you see is the external manifestation of this state machine. Once the result comes back, it either becomes <resolved> or <rejected>.
A Chain Reaction of Failure: One Rejection Spoils All
Promise.all has an iron rule: If any single Promise in the array is rejected, the whole thing is immediately rejected.
Furthermore, it doesn't wait for the other Promises to finish — the rest remain pending, and the result is discarded directly. What .catch gets is the reason for the first failure.
| Scenario | Promise.all behavior |
|---|---|
| All fulfilled | Returns an array of results, order = input order |
| One rejected | Overall rejected, goes to catch, gets the first failure reason |
| Multiple rejected | Still goes to catch, only gives the first one |
| Some still pending | No effect — it only cares if there is a reject, not progress |
This rule dictates: The prerequisite for using Promise.all is "all requests must succeed." If even one interface fails, the entire parallel batch is wasted. If you can tolerate partial failure, you need to switch to Promise.allSettled.
Serial await vs Promise.all Parallelism
First, look at a real, runnable piece of code with two mutually independent interfaces:
js
const getStory = async () =>
fetch('https://v1.hitokoto.cn/?c=i&encode=json'); // Hitokoto API
const getRatp = async () =>
fetch('https://api.1314.cool/bingimg/?type=json&rand=1'); // Bing daily image
Serial approach (naive version):
js
async function main() {
const story = await getStory(); // wait for hitokoto to return
const ratp = await getRatp(); // then wait for bingimg to return
// Total time ≈ T1 + T2
}
If each interface takes 500ms, serial execution takes 1000ms. The second interface could clearly run simultaneously with the first, but you've forced it to queue.
Parallel approach (Promise.all):
js
async function main() {
const [storyRes, ratpRes] = await Promise.all([
getStory(),
getRatp(),
]);
// Total time ≈ max(T1, T2)
}
| Approach | Total Time | Result Order |
|---|---|---|
Serial await |
T1 + T2 | Code order |
Promise.all |
max(T1, T2) | Order of the input array |
Key point: The result order of Promise.all has nothing to do with who finishes first — it collects results in the order of the input array you passed. bingimg returned before hitokoto? Doesn't matter, hitokoto is still first in the results array. This characteristic lets you confidently destructure [storyRes, ratpRes].
Nested Promise.all: Two-Stage Parallelism
Just making fetch concurrent isn't enough. fetch returns a Response object, and you need to call .json() to get the data — and .json() itself is also a Promise.
The approach in the notes is clever, the second stage is also parallel:
js
Promise.all([getStory(), getRatp()])
.then(response => {
return Promise.all(response.map(res => res.json()));
})
.then(([storyData, imgData]) => {
console.log(storyData, imgData);
})
.catch(err => {
console.log('One of them failed:', err);
});
Breaking down the two stages of parallelism:
| Stage | What it does | Parallel objects |
|---|---|---|
First Promise.all |
Sends two fetch requests simultaneously | [getStory(), getRatp()] |
Second stage in .then |
Parses the json of both responses simultaneously | response.map(res => res.json()) |
The line response.map(res => res.json()) is the essence — map converts each Response into the Promise res.json(), yielding an array of Promises, which is then fed to Promise.all.
Two stages of concurrent relay, with no point in the entire chain where it's "waiting dumbly." This is the correct way to use Promise.all.
5 Pitfall Reminders
1. Thinking res.json() inside .then is synchronous. It returns a Promise! Without await or wrapping it in Promise.all, what you get is a Promise object, not the data.
2. Writing serial await as "fake parallelism." await getStory(); await getRatp(); looks parallel but is actually serial — the two awaits are separated by waiting. For parallelism, you must use Promise.all, or first do const p1 = getStory(); const p2 = getRatp(); then await them separately.
3. Using the wrong method for interface fault tolerance. Promise.all fails entirely if one fails. If you want "successful ones get data, failed ones get a default value," switch to Promise.allSettled, which returns [{status, value/reason}, ...], and judge for yourself.
4. Mixing up result order. Don't assume "whoever returns first is placed first" — Promise.all strictly follows the input array order. If you want to process in order of completion, you need Promise.race or write it yourself.
5. Forgetting catch. If Promise.all rejects and you don't attach a .catch, the error is lost, and the console reports an UnhandledPromiseRejection. In a production environment, you must attach a catch, even if it's just for logging.
Afterword
Promise.all isn't just about being "a bit faster"; it's an expression of task dependency relationships — "this pile of things doesn't depend on each other, no one waits for anyone else." Serial await is "I absolutely must finish A before starting B," while parallel is "you all go together, I'll wait for the slowest one to return."