
Stop Using Promise.all Blindly: Safe JS Concurrency
Discover why Promise.all can cause cascading failures in JavaScript apps, and learn how to use Promise.allSettled for robust concurrent operations.
You're building a dashboard that needs to fetch data from three different third-party APIs. Or maybe you're writing a script to send a weekly newsletter to thousands of users. In both cases, you have a list of asynchronous tasks that can run concurrently.
When we write modern JavaScript, we are constantly dealing with Promises. Whether we're interacting with a database, communicating across microservices, or writing shell scripts using Node.js, asynchronous operations are the foundation of our work. But concurrency is notoriously hard to get right.
If you're like most JavaScript or TypeScript developers, your first instinct is probably to reach for Promise.all. It's the classic, go-to tool for executing multiple promises at the same time. You pass it an array of promises, it waits for them to finish, and returns an array of results. Simple, elegant, and fast.
But there's a hidden danger in Promise.all that often catches developers off guard in production: its strict "fail-fast" behavior.
The Problem: The "Fail-Fast" Trap
Promise.all is designed to be all-or-nothing. If you pass it 100 promises, and 99 of them succeed perfectly, but just one of them rejects, the entire Promise.all call immediately rejects.
Let's look at a common scenario: sending emails.
const users = [user1, user2, user3]; // Let's say user2's email is invalid
try {
await Promise.all(users.map(user => sendEmail(user.email)));
console.log('All emails sent successfully!');
} catch (error) {
console.error('Failed to send emails:', error);
}
In this example, if the API call to send user2's email fails, the catch block is triggered immediately. The success of user1 and user3 is completely swallowed. You don't get an array of results, you just get the single error from the first promise that failed.
Worse, if user3's email was still in flight when user2 failed, the Promise.all rejects immediately, but user3's email might still be sent in the background. This leaves your system in an inconsistent state where you don't know who received the email and who didn't, making retries a nightmare.
The Solution: Promise.allSettled
Introduced in ES2020, Promise.allSettled is the robust alternative for handling multiple independent async tasks. Unlike Promise.all, it never short-circuits. It waits for every single promise in the array to finish—regardless of whether they succeed or fail.
Instead of just returning the resolved values, it returns an array of objects describing the outcome of each promise.
const results = await Promise.allSettled(
users.map(user => sendEmail(user.email))
);
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('Success:', result.value);
} else {
console.error('Failed:', result.reason);
}
});
Every object in the resulting array has a status property that is either 'fulfilled' or 'rejected'. If it's fulfilled, you get the value. If it's rejected, you get the reason (the error).
This completely changes how you handle failures. You can now process the successes, log the failures, and perhaps push the failed items to a dead-letter queue for a later retry, without disrupting the entire batch. This means your application doesn't completely halt when a network timeout occurs or when a user's account is temporarily locked. Instead of an ugly 500 Internal Server Error returning to the client, you can return a 207 Multi-Status response indicating partial success, greatly improving the user experience.
Filtering Results Elegantly
A common pattern when using Promise.allSettled is to separate the successes from the failures. Here's a clean way to do that in TypeScript:
const tasks = [fetchData(1), fetchData(2), fetchData(3)];
const results = await Promise.allSettled(tasks);
const successfulData = results
.filter((r): r is PromiseFulfilledResult<Data> => r.status === 'fulfilled')
.map(r => r.value);
const failedReasons = results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map(r => r.reason);
console.log(`Succeeded: ${successfulData.length}, Failed: ${failedReasons.length}`);
This approach gives you total visibility and control over the execution batch.
When SHOULD You Use Promise.all?
I'm not saying you should ban Promise.all from your codebase. It still has a very specific and important use case: dependent, atomic operations.
Use Promise.all when the tasks are intrinsically linked, and if one fails, the others become useless. For example, if you are loading the initial state for a UI component and you need the user's profile, their settings, and their permissions to render anything meaningful.
// If any of these fail, we can't render the page, so we WANT to fail fast.
const [profile, settings, permissions] = await Promise.all([
fetchProfile(),
fetchSettings(),
fetchPermissions()
]);
In this case, the fail-fast behavior is exactly what you want. There's no point waiting for permissions to load if fetching the profile already failed.
Going Beyond: Managing Concurrency Limits
Both Promise.all and Promise.allSettled have a shared limitation: they run everything concurrently. If you pass an array of 10,000 promises, Node.js will attempt to execute 10,000 operations at once. This can quickly exhaust your memory, overload your database connections, or cause third-party APIs to rate-limit you.
When dealing with large arrays, neither native method is enough on its own. You need to implement a concurrency limit. Instead of dumping everything into Promise.allSettled, you should process items in chunks, or use a library like p-map which allows you to specify a concurrency cap.
// Using p-map to limit concurrency to 5 active promises at a time
import pMap from 'p-map';
const results = await pMap(users, async (user) => {
try {
const value = await sendEmail(user.email);
return { status: 'fulfilled', value };
} catch (reason) {
return { status: 'rejected', reason };
}
}, { concurrency: 5 });
By wrapping your task in a try/catch and mapping to the same status format, you achieve the resilience of Promise.allSettled while protecting your system from overwhelming spikes in traffic.
Conclusion: Default to Resilience
When you're dealing with independent tasks—like batch processing, sending notifications, or fetching data from disparate microservices—default to Promise.allSettled. It prevents isolated failures from causing cascading crashes in your application and provides the granular control you need for robust error handling and retries.
Think of Promise.all as a rigid transaction where everything must succeed. Think of Promise.allSettled as a resilient batch processor that does as much as it can and reports back. Choosing the right tool for the job will make your systems significantly more stable.


written by
Nguyên Tech
Responses
Loading comments…