Async/Await in JavaScript
Async functions and Await keyword are latest additions in JavaScript as part of ECMAScript 2017 release which introduced a new way of writing asynchronous functions. In this post we will talk about why we should use async/wait, its syntax and practical usage with example.
Why Async/Await?
In earlier days, you would have used callbacks to handle asynchronous operations. However, callbacks have limited functionality and often leads to unmanageable code if you are handling multiple async calls, it leads to heavily nested callback code which is also known as callback hell.
Later, Promises were introduced in ES6 to overcome the problems of callback functions and improved code readability. Finally Async/Await introduced in ES2017, which are nothing but the syntactic improved version of promises. Its underlying is Promise with improved syntax which provides,
- better way to chain promises and pass values between chained promises
- more concise and readable code compare to promises
- debugging is easy
- better error handling
Also read this post for more details on promises in javascript
Syntax
async
When async keyword is applied before a function, it turns into asynchronous function and always return a promise object.
1async function hello() {
2 //return Promise.resolve("Hello");
3 return "Hello";
4}
5
6console.log(hello());
7hello().then(data => console.log(data));
Output
Promise {<resolved>: "Hello"}
Hello
We see in above code snippet that when we execute async function hello(), it wraps the string value in promise object and return a resolved promise. We can also explicitly return the resolved promise object, line 2 and line 3 are same.
We further used then on resolved promise object to get Hello string (line 7)
await
// works only inside async functions
let value = await promise;
await keyword works only inside async function and it makes the function execution wait until the returned promise settles (either resolve or reject).
1async function hello() {
2 let promise = new Promise((resolve, reject) => {
3 setTimeout(() => resolve("Hello"), 5000)
4 });
5
6 let value = await promise; // wait until the promise resolves
7
8 return value;
9}
10
11hello().then(data => console.log(data));
Please note that in above code snippet when we execute async function hello(), function execution literally waits for 5s at line 6 before returning resolved promise object. CPU resources are not utilized in this wait period and can be used for other work.
Also note that if you use await keyword inside non-async function, it returns SyntaxError like below:
function hello() {
let promise = Promise.resolve("Hello");
let value = await promise; ⓧ Uncaught SyntaxError: await is only valid in async function
return value;
}
Usage
Let’s see one of the practical example of getting data from multiple HTTP endpoints using fetch API.
1. Create three promise objects
We have created a common function getData and used this to create three parameterized promise objects getUser, getPosts and getComments to fetch data from their respective HTTP endpoint.
//create a common getData function
let getData = (url) => new Promise(function (resolve, reject ){
fetch(url)
.then(response => {
return response.json();
})
.then(data => {
resolve(data);
})
.catch(error => {
reject(error);
});
});
//create multiple promises from common getData function
let getUsers = getData('https://jsonplaceholder.typicode.com/users');
let getPosts = (userId) => getData(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`);
let getComments = (postId) => getData(`https://jsonplaceholder.typicode.com/comments?postId=${postId}`);
2. Promise Chaining
Our goal is to fetch all comments on first post of first user.
We are first fetching all users from getUsers promise and chaining it with getPost promise by passing firstUser. Further chaining it with getComments promise by passing firstPost.
//promise chaining of multiple asynchronous calls
getUsers.then(users => {
let firstUser = users[0];
return getPosts(firstUser.id);
}).then(posts => {
let firstPost = posts[0];
return getComments(firstPost.id);
}).then(comments => {
console.log(comments);
}).catch(error => console.error(error));
Output
▼ (5) [{…}, {…}, {…}, {…}, {…}]
➤ 0: {postId: 1, id: 1, name: "id labore ex et quam laborum", email: "Eliseo@gardner.biz", body: "laudantium enim quasi est quidem magnam voluptate …utem quasi↵reiciendis et nam sapiente accusantium"}
➤ 1: {postId: 1, id: 2, name: "quo vero reiciendis velit similique earum", email: "Jayne_Kuhic@sydney.com", body: "est natus enim nihil est dolore omnis voluptatem n…iatur↵nihil sint nostrum voluptatem reiciendis et"}
➤ 2: {postId: 1, id: 3, name: "odio adipisci rerum aut animi", email: "Nikita@garfield.biz", body: "quia molestiae reprehenderit quasi aspernatur↵aut …mus et vero voluptates excepturi deleniti ratione"}
➤ 3: {postId: 1, id: 4, name: "alias odio sit", email: "Lew@alysha.tv", body: "non et atque↵occaecati deserunt quas accusantium u…r itaque dolor↵et qui rerum deleniti ut occaecati"}
➤ 4: {postId: 1, id: 5, name: "vero eaque aliquid doloribus et culpa", email: "Hayden@althea.biz", body: "harum non quasi et ratione↵tempore iure ex volupta…ugit inventore cupiditate↵voluptates magni quo et"}
length: 5
➤ __proto__: Array(0)
3. async/await
Let’s achieve the same goal of fetching comments using async/await,
//async and await makes code cleaner and readable
async function getCommentsOfFirstPostByFirstUser(){
let users = await getUsers;
let firstUser = users[0];
let posts = await getPosts(firstUser.id);
let firstPost = posts[0];
let comments = await getComments(firstPost.id);
return comments;
}
getCommentsOfFirstPostByFirstUser().then(comments => console.log(comments));
Output
▼ (5) [{…}, {…}, {…}, {…}, {…}]
➤ 0: {postId: 1, id: 1, name: "id labore ex et quam laborum", email: "Eliseo@gardner.biz", body: "laudantium enim quasi est quidem magnam voluptate …utem quasi↵reiciendis et nam sapiente accusantium"}
➤ 1: {postId: 1, id: 2, name: "quo vero reiciendis velit similique earum", email: "Jayne_Kuhic@sydney.com", body: "est natus enim nihil est dolore omnis voluptatem n…iatur↵nihil sint nostrum voluptatem reiciendis et"}
➤ 2: {postId: 1, id: 3, name: "odio adipisci rerum aut animi", email: "Nikita@garfield.biz", body: "quia molestiae reprehenderit quasi aspernatur↵aut …mus et vero voluptates excepturi deleniti ratione"}
➤ 3: {postId: 1, id: 4, name: "alias odio sit", email: "Lew@alysha.tv", body: "non et atque↵occaecati deserunt quas accusantium u…r itaque dolor↵et qui rerum deleniti ut occaecati"}
➤ 4: {postId: 1, id: 5, name: "vero eaque aliquid doloribus et culpa", email: "Hayden@althea.biz", body: "harum non quasi et ratione↵tempore iure ex volupta…ugit inventore cupiditate↵voluptates magni quo et"}
length: 5
➤ __proto__: Array(0)
Error Handling with try/catch
One thing the examples above skip over: what happens when a promise in the chain rejects? With .then() chaining you handle that with .catch(). With async/await, the equivalent is a plain try/catch block wrapped around your await calls.
Without a try/catch, a rejected promise inside an async function simply propagates as a rejected promise from the function itself — nothing “catches” it for you.
1function getData(shouldFail) {
2 return new Promise((resolve, reject) => {
3 setTimeout(() => {
4 if (shouldFail) {
5 reject(new Error("Network error: could not fetch comments"));
6 } else {
7 resolve(["comment1", "comment2"]);
8 }
9 }, 50);
10 });
11}
12
13// no try/catch - the rejection is never handled
14async function getCommentsUnhandled() {
15 let comments = await getData(true);
16 return comments;
17}
18
19getCommentsUnhandled().then(result => console.log("Result:", result));
Run this and Node doesn’t just log a warning and move on — an unhandled promise rejection crashes the process:
Output
Error: Network error: could not fetch comments
at Timeout._onTimeout (unhandled.js:5:16)
at listOnTimeout (node:internal/timers:585:17)
at process.processTimers (node:internal/timers:521:7)
Node.js v22.22.2
(Exact wording depends on your Node version — older versions print an UnhandledPromiseRejectionWarning instead of crashing, but since Node 15 an unhandled rejection terminates the process by default. Either way, the point stands: nothing in the function above ever handles the error.)
Wrap the await in try/catch and you get a chance to handle it gracefully instead:
1async function getCommentsHandled() {
2 try {
3 let comments = await getData(true);
4 return comments;
5 } catch (error) {
6 console.log("Caught error:", error.message);
7 return [];
8 }
9}
10
11getCommentsHandled().then(result => console.log("Result:", result));
Output
Caught error: Network error: could not fetch comments
Result: []
Applying this to the earlier getCommentsOfFirstPostByFirstUser example, you’d wrap the three await calls in a single try block and handle the failure in one catch, rather than chaining a separate .catch() onto every step:
1async function getCommentsOfFirstPostByFirstUser(){
2 try {
3 let users = await getUsers;
4 let firstUser = users[0];
5 let posts = await getPosts(firstUser.id);
6 let firstPost = posts[0];
7 let comments = await getComments(firstPost.id);
8 return comments;
9 } catch (error) {
10 console.error("Failed to fetch comments:", error);
11 return [];
12 }
13}
Anti-Pattern: Unnecessary Sequential Awaits
Look back at getCommentsOfFirstPostByFirstUser — each await runs one after another. That’s required here because each call genuinely depends on the previous one’s result (you need firstUser.id before you can fetch posts). But it’s extremely common to see code that awaits several calls in sequence even when they don’t depend on each other at all, which just wastes time waiting for each one to finish before starting the next.
Here’s the anti-pattern, using setTimeout-based mock promises so it’s runnable without any real network calls:
1function delay(ms, value) {
2 return new Promise(resolve => setTimeout(() => resolve(value), ms));
3}
4
5async function sequential() {
6 const start = Date.now();
7 const a = await delay(300, "a"); // waits 300ms
8 const b = await delay(300, "b"); // then waits another 300ms
9 const c = await delay(300, "c"); // then another 300ms
10 console.log("Sequential took", Date.now() - start, "ms ->", a, b, c);
11}
12
13sequential();
Output
Sequential took 902 ms -> a b c
Since a, b, and c don’t depend on each other, there’s no reason to wait for each one before starting the next. Promise.all() fixes this by starting all three promises immediately and waiting for all of them to settle together:
1async function concurrent() {
2 const start = Date.now();
3 const [a, b, c] = await Promise.all([delay(300, "a"), delay(300, "b"), delay(300, "c")]);
4 console.log("Promise.all took", Date.now() - start, "ms ->", a, b, c);
5}
6
7concurrent();
Output
Promise.all took 301 ms -> a b c
Same result, roughly a third of the time — because all three 300ms delays now overlap instead of stacking up. Promise.all() rejects as soon as any one of its promises rejects, so pair it with the try/catch from the previous section when the calls can fail.
Pitfall: await Inside Array.forEach() Doesn’t Wait
A very common bug: passing an async callback to Array.prototype.forEach() and expecting the loop to wait for each iteration before moving on. It won’t — forEach has no idea the callback is async, it just calls it and ignores whatever promise it returns. It doesn’t wait, and it doesn’t propagate rejections either.
1function delay(ms, value) {
2 return new Promise(resolve => setTimeout(() => resolve(value), ms));
3}
4
5async function processWithForEach() {
6 const ids = [1, 2, 3];
7 console.log("forEach: starting");
8 ids.forEach(async (id) => {
9 const result = await delay(100, `item-${id}`);
10 console.log("forEach: processed", result);
11 });
12 console.log("forEach: finished (but has it, really?)");
13}
14
15processWithForEach();
Output
forEach: starting
forEach: finished (but has it, really?)
forEach: processed item-1
forEach: processed item-2
forEach: processed item-3
Notice "forEach: finished" logs before any item is processed — the function returned while the three delay() calls were still pending in the background. If the calling code assumed all items were done by the time processWithForEach() resolved, that assumption is wrong.
The fix is either a for...of loop (which respects await, running iterations one at a time):
1async function processWithForOf() {
2 const ids = [1, 2, 3];
3 console.log("for...of: starting");
4 for (const id of ids) {
5 const result = await delay(100, `item-${id}`);
6 console.log("for...of: processed", result);
7 }
8 console.log("for...of: finished (this really is last)");
9}
Output
for...of: starting
for...of: processed item-1
for...of: processed item-2
for...of: processed item-3
for...of: finished (this really is last)
…or, if the items don’t depend on each other and you want them running concurrently rather than one-by-one, Promise.all() with .map():
1async function processWithMap() {
2 const ids = [1, 2, 3];
3 await Promise.all(ids.map(async (id) => {
4 const result = await delay(100, `item-${id}`);
5 console.log("map: processed", result);
6 }));
7 console.log("map: finished (this really is last, and it's faster too)");
8}
Either works — pick for...of when order matters or each step depends on the last, and Promise.all(array.map(...)) when the items are independent and you want them to run in parallel. Just never reach for forEach when you need the loop to actually wait.
Summary
We see that async/await are much easier to use as compare to promises.