Hello readers 👋, welcome to the 22nd blog in this JavaScript series!
In our last post, we learned how try...catch helps us build resilient applications that handle failures gracefully. Today we are going to explore something that makes asynchronous code feel almost as straightforward as synchronous code: async/await.
If you have ever wished that promise chains could read like a simple sequence of steps, or that you could use try...catch directly with async operations, async/await is exactly what you need. It doesn't replace promises; it builds on top of them to make our code cleaner and more intuitive. Let's understand it step by step.
Why async/await was introduced
Promises already solved the callback hell problem. We went from deeply nested callbacks to flat chains of .then(). But even with promises, complex logic can still look a little noisy. Consider a scenario where you need to:
- Fetch a user from an API
- If the user has a certain role, fetch extra permissions
- Use the result to update the UI
With promises alone, you might write something like:
fetchUser(userId)
.then(user => {
if (user.role === "admin") {
return fetchPermissions(user.id)
.then(permissions => {
return { user, permissions };
});
}
return { user, permissions: [] };
})
.then(result => {
console.log("Data ready:", result);
})
.catch(error => {
console.log("Failed:", error);
});
This works, but mixing conditional logic with nested .then() calls makes the flow harder to read. Debugging multiple chained .then() blocks can also be tricky because the stack trace gets cluttered. Async/await was introduced to let us write code that looks and behaves more like regular synchronous code, while still being non-blocking under the hood.
How async functions work
An async function is a function declared with the async keyword. Every async function automatically returns a promise. Whatever value you return from the function is wrapped in a resolved promise. If the function throws an error, the returned promise is rejected.
Here is the simplest form:
async function greet() {
return "Hello!";
}
console.log(greet()); // Promise { <fulfilled>: "Hello!" }
greet().then(value => console.log(value)); // Hello!
Even though we returned a plain string, the caller receives a promise. This means we can use .then(), .catch(), or await on it just like any other promise.
Inside an async function, we can use the await keyword to pause execution until a promise settles. That is where the real magic begins.
The await keyword concept
The await keyword can only be used inside an async function (or at the top level of a module in modern JavaScript). It causes the function execution to pause until the promise on its right-hand side settles, and then it returns the resolved value.
Let's start with a simple delay:
function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function run() {
console.log("Start");
await wait(2000);
console.log("Two seconds later");
}
run();
console.log("This logs immediately");
The output order is:
Start
This logs immediately
Two seconds later
When the JavaScript engine hits await wait(2000), the run function suspends. The event loop is free to execute other code, so the outer console.log runs immediately. After two seconds, the promise resolves, and run resumes exactly where it left off.
The key insight: await does not block the main thread. It just makes the async function wait while letting the rest of the program continue. This is exactly what we want.
Rewriting promise chains with async/await
Let's rewrite the earlier user fetching example with async/await. We will use a real API (JSONPlaceholder) and break down the code to see the improvement.
First, the promise version:
function fetchUser(id) {
return fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
.then(res => {
if (!res.ok) throw new Error("Network error");
return res.json();
});
}
function fetchPosts(userId) {
return fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`)
.then(res => res.json());
}
// Promise chain
fetchUser(1)
.then(user => {
console.log("User:", user.name);
return fetchPosts(user.id);
})
.then(posts => {
console.log("Number of posts:", posts.length);
})
.catch(error => {
console.log("Error:", error.message);
});
Now the async/await version:
async function displayUserData(userId) {
try {
const user = await fetchUser(userId);
console.log("User:", user.name);
const posts = await fetchPosts(user.id);
console.log("Number of posts:", posts.length);
} catch (error) {
console.log("Error:", error.message);
}
}
displayUserData(1);
The difference is striking. The async/await version looks like a normal sequence of steps: wait for user, then log it, then wait for posts, then log them. The variables user and posts are directly available, and we used a simple try...catch block for error handling. No more chaining .then() and passing data through callbacks.
Error handling with async/await
Error handling with async/await is much more natural because we can use the familiar try...catch syntax directly around asynchronous code. If any awaited promise rejects, or if any code inside the try block throws, the catch block catches it.
async function riskyOperation() {
try {
const response = await fetch("invalid-url");
const data = await response.json();
console.log(data);
} catch (error) {
console.log("Something went wrong:", error.message);
}
}
You can also catch errors at the call site when you call an async function, because the function itself returns a promise:
async function getData() {
throw new Error("Oops");
}
getData().catch(error => console.log(error.message));
Using both together lets you choose the granularity of error handling. You can handle errors locally with try...catch inside the async function, or let them propagate to the caller.
Comparison with promises
Async/await is syntactic sugar over promises, which means everything you can do with async/await you can do with promises. But the improvement in readability and simplicity is massive.
Let's compare the two approaches side by side:
| Aspect | Promises | Async/Await |
|---|---|---|
| Readability | Chain of .then() calls, can become nested with conditions |
Linear code that reads like synchronous steps |
| Error handling |
.catch() at the end of chain, or a second argument in .then()
|
Standard try...catch blocks |
| Debugging | Stack traces can be confusing; breakpoints may skip async gaps | More intuitive stack traces; you can step through the code like sync |
| Intermediate values | Need to thread values through the chain or use external variables | Values are assigned to variables and stay in scope |
| Conditional logic | Requires nesting .then() or using Promise.resolve() placeholders |
Use regular if statements, no nesting |
Here is a concrete example that shows the comfort of conditional logic with async/await:
Inline with promises (somewhat messy):
fetchUser(id).then(user => {
if (user.isAdmin) {
return fetchAdminData(user.id).then(adminData => {
console.log(adminData);
});
} else {
console.log("Not an admin");
}
});
Inline with async/await:
async function handleUser(id) {
const user = await fetchUser(id);
if (user.isAdmin) {
const adminData = await fetchAdminData(user.id);
console.log(adminData);
} else {
console.log("Not an admin");
}
}
The async/await version is clearly cleaner. The flow is visible at a glance, and we didn't add any extra nesting.
Avoiding common pitfalls
While async/await is wonderful, there are a few traps to watch out for:
1. Using await outside an async function
This will throw a syntax error. If you need to use await at the top level, you can use an async IIFE (Immediately Invoked Function Expression) or, in modern environments, top-level await in a module.
// IIFE workaround
(async () => {
const result = await someAsyncFunc();
console.log(result);
})();
2. Forgetting to handle rejections
If an async function is called and its promise is not handled with .catch() or try...catch, then a rejected promise will go unhandled. This can lead to hidden bugs. Always handle errors somewhere in the chain.
3. Unnecessary sequential execution
When you need to run multiple independent async operations, be careful not to await them one by one if they could run in parallel. That can slow down your program.
// Sequential, slower
const user = await fetchUser(1);
const posts = await fetchPosts(1);
// Parallel, faster
const [user, posts] = await Promise.all([fetchUser(1), fetchPosts(1)]);
Use Promise.all (or allSettled) when the operations don't depend on each other.
Visualizing the execution flow
I often picture an async function as a bookmark in a book. When the function hits an await, it puts a bookmark on that line, closes the book, and goes to do other tasks. The event loop keeps turning. When the awaited promise resolves, the function opens the book again, flips to the bookmark, and continues reading from that exact point.
A typical async function flow looks like this:
This "pause and resume" behavior is why debugging async/await feels like debugging synchronous code. You can set breakpoints and step through the lines in the same linear order they appear.
Conclusion
Async/await is one of the most loved features of modern JavaScript, and for good reason. It takes the solid foundation of promises and adds a layer of readability that makes asynchronous code feel natural.
To recap what we learned:
-
asyncmakes a function return a promise automatically. -
awaitpauses the execution of an async function until a promise settles, without blocking the main thread. - Error handling uses
try...catch, mirroring synchronous code patterns. - Async/await greatly improves readability, especially with conditional logic and sequential async steps.
- It is not a replacement for promises but a powerful syntax built on top of them.
- Be mindful of sequential vs parallel execution; use
Promise.allwhen you can.
With async/await in your toolkit, you can write code that tells a clear story, step by step, no matter how many asynchronous operations it needs to coordinate. It's a joy to read and a pleasure to maintain.
Hope you found this helpful! If you spot any mistakes or have suggestions, let me know. You can find me on LinkedIn and X, where I post more about web development.

Top comments (0)