Improving Performance of Async Operations in JavaScript.
If you’re reading this, you’ve probably worked with promises before. And when I say promises, I don’t mean the ones made by your friend or partner. Most of the time, they’re API calls on the client or database queries on the server—basically, anything async in JavaScript.
The Problem With Sequential Execution
Let’s look at an example that uses sequential execution to understand what the problem is. Here’s an example function that gets the user data. The example is highly abstracted for simplicity.
async function getUserData(userId: string): Promise<UserData> {
const posts = await getPosts(userId); // 1000ms
const followers = await getFollowers(userId); // 1000ms
const following = await getFollowing(userId); // 1000ms
return { posts, followers, following };
}
const data = await getUserData(userId); // Time to get user data 3s
At first glance, it’s tough to spot what’s wrong here. But technically, each request waits for the previous one to finish before starting, which leads to higher delays. Unless your server responds within 100ms for each request across the globe, this delay could be even worse for people living far away from your database. So, what can we do?
For starters, we could place more databases closer to your users. Umm, that doesn’t sound very practical—unless you have a lot of VC money to burn on infra.
The second thing we can do is simple: read the docs. The docs about Promises. It has a hidden gem, and it’s called Promise.all().
What is Promise.all()?
Promise.all() runs all your promises concurrently. Which means if you have multiple promises, such as above, they all can be fired concurrently instead of waiting for each one to complete. Here’s a refactored example that uses Promise.all() to fire promises concurrently.
async function getUserData(userId: string): Promise<UserData> {
const [posts, followers, following] = await Promise.all([
getPosts(userId), // 1000ms
getFollowers(userId), // 1000ms
getFollowing(userId), // 1000ms
]);
return { posts, followers, following };
}
const data = await getUserData(userId); // Time to get user data 1s
Hell yeah, that’s 3 times faster than the original implementation. But wait, if everything about this is so nice, why don’t we use it everywhere? Well, if you write software, you know it’s a game of trade-offs. There’s not one solution you can use for everything and call it a day. Here’s why:
While Promise.all() works great for independent tasks, it’s not the best fit for dependent ones. If the result of one promise is required for the next, sequential execution is still your best friend. Here’s an example:
async function getOrderDetails(request: Request): Promise<OrderDetails> {
const user = await getUser(request);
const orders = await getOrders(user.id); // here, user.id is required so Promise.all() can't be used
const orderDetails = await getOrderDetailsById(orders[0].id); // here, orders[0].id is required so Promise.all() can't be used
return { user, orders, orderDetails };
}
const details = await getOrderDetails(userId);
In this case, each step depends on the previous one. Using Promise.all() here would result in errors since the dependent data wouldn’t be available when needed.
For scenarios like above, firing them sequentially is still the best option. Here’s a benchmark that shows the difference between firing them concurrently and sequentially.
Handling Errors with Promise.all()
One issue with Promise.all() is that it fails fast—if any promise rejects, the entire operation fails. You can handle this by wrapping each promise in a .catch()
block:
async function getUserData(userId: string): Promise<UserData> {
const [posts, followers, following] = await Promise.all([
getPosts(userId).catch(() => []),
getFollowers(userId).catch(() => []),
getFollowing(userId).catch(() => []),
]);
return { posts, followers, following };
}
This makes sure that a single failure doesn’t disrupt the entire batch, and allows partial results to still be processed. But wait, that doesn’t look very nice, right? I mean, just wrapping each promise in a catch
block feels clunky and like a wasted opportunity to handle errors properly.
Enter Promise.allSettled() — a cleaner and more structured way to deal with multiple promises when you care about all the outcomes, not just the successful ones. It waits for every promise to settle (either fulfilled or rejected) and gives you a neat summary of what happened, so you can handle successes and failures in one go, without any try-catch gymnastics.
Takeaways
- Using Promise.all() is an good way to improve the performance of asynchronous operations by running them concurrently. It’s especially useful when the operations are independent of each other and don’t rely on sequential execution.
- Use Promise.all() to execute multiple independent promises concurrently, reducing execution time.
- Handle errors gracefully with
.catch()
blocks or consider Promise.allSettled() for detailed error handling. - Avoid Promise.all() for dependent tasks where sequential execution is required.
Experiment with Promise.all() in your next project, and let your async tasks perform better!