Well, more often than not while building stuff with JS/TS, we tend to use await whenever we come across an API call (or something which goes out of the synchronous flow of the main thread execution). That takes away the concerns of handling all the asynchronicity that the language offers — and NGL, it’s the easiest way out. No callbacks (hence no chances of a callback hell), .then() syntax. Our code executes line-by-line, just as would have happened if the language followed a fully synchronous paradigm.
// Simulates an async function
async function foo() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("foo");
}, 300);
});
}
(async () => {
const res = await foo();
console.log(res);
})();
💻 Guess the output of the code snippet.
It's foo.
Straightforward, isn't it?
Now, take two scenarios where multiple async calls are being handled in a single function.
But before that, let's prepare the stage for the whole demonstration.
Let's create a fake DB using JS objects.
// Consider it as a fake DB
const users = [
{ id: 1, name: "Krishnendu", age:23 },
{ id: 2, name: "Rohan", age:25 },
];
const posts = [
{ id: 101, userId: 1, title: "Hello World" },
{ id: 102, userId: 1, title: "Another Hello World" },
{ id: 103, userId: 2, title: "Just for posting" },
];
Consider these utility functions, which are async in nature and would be useful to fetch the various details of user, posts and jokes.
// Simulating a fake DB call for fetching a user
const getUser = (id) =>
new Promise((resolve) => {
setTimeout(() => resolve(users.find((u) => u.id === id)), 3000);
});
// Simulating a fake DB call for fetching user posts
const getPosts = (userId) =>
new Promise((resolve) => {
setTimeout(() => resolve(posts.filter((p) => p.userId === userId)), 3000);
});
// API call for generating jokes
const generateJoke = async () => {
const res = await fetch(
"https://official-joke-api.appspot.com/random_joke"
);
return res.json();
};
Scenario - 1
(async () => {
try {
// Fetching the User
const user = await getUser(1);
// If user not found, then we just stop the further execution
if (!user) {
throw new Error("User not found");
}
// User found, so find posts
const userPosts = await getPosts(user.id);
console.log(user);
console.log(userPosts);
} catch (err) {
console.error("Error:", err.message);
}
})();
In the above code snippet, two asynchronous calls have been handled in the most conventional way. First we wait for the getUser call to finish, then we hop on to the getPosts call.
Scenario - 2
(async () => {
let userDetails = null;
try {
// fetch user
userDetails = await getUser(1);
} catch (error) {
console.log("User fetch failed");
}
// fetch joke
const joke = await generateJoke();
// output
if (userDetails) {
console.log(
`User: ${userDetails.name}, Age: ${userDetails.age}`
);
} else {
console.log("No User found");
}
console.log(`Joke: ${joke.setup} ${joke.punchline}`);
})();
Similarly here, we wait for the completion of getUser call only to jump on the next generateJoke call.
Just for the note (to be used later), let's analyse the time taken for this snippet. Let's say the getUser takes almost 3000 milliseconds to finish and generateJoke takes almost 2000 milliseconds.
getUser(3000ms) + generateJoke(2000ms) ≈ 5000ms
Now if we analyse carefully, for Scenario-1, it does make sense to wait for the first call of user fetching and then move on to the next call of finding the corresponding posts. Those are Interdependent calls. But for Scenario-2, both the calls are not even related to each other, let alone their dependence. So aren't we killing the computational time while waiting for the first call of fetching user details? 🤔
And there comes the actual scope for leveraging concurrency in your code. What if, instead of waiting for the previous call to finish, we start both (or multiple) calls at the same time, let them execute CONCURRENTLY and in the end we wait for the accumulated result? That would save us idle wait time (a lot in some cases), so that the total execution time is closer to the slowest task in the queue, rather than the sum of execution times of all tasks.
Promise.all()
Good news is that JavaScript (and also TypeScript) inherently addresses the above concern and exposes a way of handling multiple asynchronous calls, without letting go of the concurrent behaviour.
Promise.all() takes in a collection (or more formally in JS/TS, an array) of promises and itself returns a Promise.
Promise.all() preserves the order of the input promises, so the resolved values are returned in the same order, making the result array easy to index.
Below code snippet demonstrates the same.
(async () => {
// Takes in an array of promises and returns a promise
const resultantPromise = Promise.all([getUser(1), generateJoke()]);
// Promise.all() preserves the order of async calls, so its very easy to extract the results
const [userDetails, jokeFetched] = await resultantPromise;
if (userDetails) {
console.log(
`User's name is ${userDetails.name} and age is ${userDetails.age}`,
);
} else {
console.log("No User found");
}
console.log(`Joke fetched: ${jokeFetched.setup} ${jokeFetched.punchline}`);
})();
Now, taking the above execution time assumptions, if we analyse the time taken for this snippet comes out as:
getUser(3000ms) andgenerateJoke(2000ms) are fired at the same time.. Now the slowest task ends at3000msand all the computations are finished by then. Resultant time ≈3000ms. We achieve almost1.6xspeedup in computational time just by properly orchestrating the async calls.
So… that’s it, right? Promise.all() does all the magic? 👀
Yeah… not quite.
Alright, roll the cameras. 🎬
Promise.allSettled()
But why? Promise.all() lets us handle all the async calls efficiently and concurrently. Then why this new method?
Yes, that's true but there's a big caveat with Promise.all(). If any of the async calls fail, the resultant promise inherently gets rejected. Now, that's not an ideal situation for all use cases.
There comes Promise.allSettled(). Similar to Promise.all() in usage, but very different in behavior, it doesn’t fail just because one of the promises fails. Instead the resultant Promise always resolves and returns an array of result objects. It waits for all promises to settle (either fulfilled or rejected) before returning the results.
Array of objects? 🤔
Yes. Each element in the array contains the status of the async call and the corresponding value (if the async call succeeds) or reason (if the call fails).
Below code snippet makes it a bit clear.
(async () => {
// Promise.allSettled() preserves the order of async calls, so its very easy to extract the results
const [userResult, jokeResult] = await Promise.allSettled([
getUser(1),
generateJoke(),
]);
// If the promise is resolved then the "status" is always "fulfilled" else it is always "rejected"
if (userResult.status === "fulfilled") {
const userDetails = userResult.value;
console.log(
`User's name is ${userDetails.name} and age is ${userDetails.age}`,
);
} else {
console.log("No User found");
}
// Similarly, for the joke API call, we can explicitly handle both "resolve" and "reject"
if (jokeResult.status === "fulfilled") {
const jokeFetched = jokeResult.value;
console.log(`Joke fetched: ${jokeFetched.setup} ${jokeFetched.punchline}`);
} else {
console.log("Joke fetching failure");
}
})();
Here, even if the getUser() call fails, we still can hope the generateJoke() to succeed.
Clearly, we have explicit control over each async call and its corresponding outcome and each outcome is independent of the others.
Choosing the right method
Honestly, there can be three common scenarios while dealing with multiple async calls and each can be handled with a specific method.
When your calls are dependent on each other, use the conventional
awaitmethodology. Handle them sequentially, since later computations depend on earlier results.-
When the calls are independent of each other, there are two common scenarios:
- Stop the computation if any one fails → use
Promise.all(). - Wait for all the calls to finish, regardless of the other calls' outcomes → use
Promise.allSettled().
- Stop the computation if any one fails → use
Other use cases might involve Promise.any() or Promise.race(), but that's a story for another day.
Conclusion
Well, JS/TS provides a variety of options to handle concurrency and each of them was designed with a specific use case in mind.
Use them wisely! 😎
Don’t just write async code — understand it. 👋
Top comments (0)