A Complete Guide to Promises and Async/Await
Starting from the pain of callback hell, break down Promise's three states and chainable .then, see how async/await wraps asynchronous code in a synchronous-looking syntax, and walk away with practical patterns for error handling and running things in parallel.
Picture this: you need to do three things in order — fetch a user, use that user to fetch their orders, then fetch the order details. Simple enough, except now you've got three nested callbacks, indentation crawling further right with each layer, and no easy way to tell which layer blew up when something goes wrong.
This pattern has a name: callback hell. Promises and async/await exist specifically to solve it. By the end of this article, you'll understand exactly what problem each one solves and how they work together.
What Callback Hell Actually Looks Like
Say you have three async functions, each one needing the previous one to finish before it can start:
getUser(
userId,
(user) => {
getOrders(
user.id,
(orders) => {
getOrderDetail(
orders[0].id,
(detail) => {
console.log(detail)
},
(err) => console.log(err),
)
},
(err) => console.log(err),
)
},
(err) => console.log(err),
)Every layer needs both a success and an error callback, the indentation packs in tighter with each nested level, and you end up writing the same error-handling logic three times over. Code like this is painful to read and even more painful to modify — and that's exactly the problem Promises set out to fix.
What Is a Promise? Think of It as a Claim Ticket
A Promise works a lot like a claim ticket at a pickup counter: it isn't the item itself, but it guarantees that eventually you'll get one of two outcomes — you either pick up your item, or you get told it's out of stock. Only one of those happens, and once it does, that outcome is locked in for good.
A Promise only ever has three states:
- pending: no outcome yet — your order is still in transit.
- fulfilled: the async operation completed successfully and you've got your data.
- rejected: the async operation failed.
A Promise can only transition from pending to either fulfilled or rejected, and that transition happens exactly once. This is called being settled — once settled, the state is frozen and won't change again.
Here's what creating a Promise looks like:
const fetchData = new Promise((resolve, reject) => {
setTimeout(() => {
const success = true
if (success) {
resolve('Got the data') // state becomes fulfilled
} else {
reject(new Error('Failed to fetch data')) // state becomes rejected
}
}, 1000)
})resolve and reject are two keys — whichever one you call determines which state the claim ticket ultimately settles into. Once you have a Promise, you catch the success case with .then and the failure case with .catch:
fetchData
.then((result) => console.log(result)) // Got the data
.catch((error) => console.log(error.message))How Do Promises Solve Callback Hell? Chaining
.then has one crucial design detail: it returns a brand-new Promise of its own. That means you can chain .then calls one after another, flattening what used to be deeply nested callbacks into a single top-to-bottom chain:
getUser(userId)
.then((user) => getOrders(user.id))
.then((orders) => getOrderDetail(orders[0].id))
.then((detail) => console.log(detail))
.catch((err) => console.log(err)) // any error at any step gets caught hereNotice you only need one .catch for the whole chain. That's because of how Promise chains behave: the moment any link in the chain rejects, every .then after it gets skipped, and execution jumps straight to the nearest .catch. This is one of the big advantages Promises have over callbacks — error handling is centralized instead of duplicated at every layer.
When you need to wait on several unrelated async operations at once, Promise.all is the tool for the job:
Promise.all([fetchUser(), fetchPosts(), fetchComments()]).then(([user, posts, comments]) => {
console.log(user, posts, comments)
}) // only runs once all three Promises are fulfilled; if even one rejects, the whole thing rejects immediatelyAsync/Await: Asynchronous Code Wearing a Synchronous Disguise
A Promise chain already reads far better than nested callbacks, but a long stack of .then calls still feels more like reading an instruction manual than following a story. Async/await is syntactic sugar layered on top of Promises, making asynchronous code look almost exactly like synchronous code.
The rules are simple:
- Put
asyncin front of a function, and it automatically returns a Promise. - Put
awaitin front of a Promise inside that function, and execution "pauses" right there until the Promise settles, before moving on to the next line.
Rewrite that .then chain from earlier using async/await:
async function loadOrderDetail(userId) {
const user = await getUser(userId)
const orders = await getOrders(user.id)
const detail = await getOrderDetail(orders[0].id)
console.log(detail)
}Looks almost exactly like synchronous code, right? No nested indentation, no pile of .then calls — it's immediately obvious how data flows from one step to the next.
One common misunderstanding worth clearing up: await doesn't actually block the whole JavaScript program while it waits. It only pauses the current async function, wrapping everything after it into a microtask that gets queued up for the event loop. While it's waiting, the call stack is empty, so the rest of your code keeps running as normal. If you want the scheduling details behind this, check out the event loop article.
Error Handling: try/catch Instead of .catch
Promise chains catch errors with .catch; async/await brings back the try/catch syntax you already know:
async function loadOrderDetail(userId) {
try {
const user = await getUser(userId)
const orders = await getOrders(user.id)
const detail = await getOrderDetail(orders[0].id)
console.log(detail)
} catch (err) {
console.log('Error:', err.message) // any await that fails gets caught here
}
}The moment any awaited Promise inside the try block rejects, execution jumps straight to catch and skips everything after it — the same logic as a Promise chain's .catch, just expressed in the more familiar syntax of synchronous error handling.
Don't Serialize Operations That Should Run in Parallel — A Common Performance Trap
Here's an easy trap to fall into: accidentally writing three unrelated async operations so they wait on each other in sequence:
async function loadAll() {
const user = await fetchUser() // say this takes 1 second
const posts = await fetchPosts() // say this takes 1 second
const comments = await fetchComments() // say this takes 1 second
return { user, posts, comments }
}
// total time: about 3 seconds, even though none of these depend on each otherSince fetchPosts doesn't need the result of fetchUser to start, all three operations can kick off at the same time. The right approach is to fire all three Promises at once with Promise.all, then await them all together:
async function loadAll() {
const [user, posts, comments] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()])
return { user, posts, comments }
}
// all three run concurrently, total time: about 1 secondThe rule of thumb is simple: only chain await calls sequentially when a later operation actually needs the result of an earlier one. If they're independent, always run them in parallel with Promise.all.
Wrapping Up
The root problem with callback hell is that deeply nested callbacks make code hard to read and hard to handle errors for in one place. Promises fix this with their claim-ticket-style three states — pending, fulfilled, rejected — and a chainable .then that flattens the nesting into a single line, letting error handling collapse into one .catch. Async/await then wraps another layer of syntactic sugar on top of Promises, so asynchronous code reads like synchronous code and error handling goes back to the familiar try/catch. Keep one practical rule in mind: chain await calls sequentially only when operations genuinely depend on each other, and reach for Promise.all whenever they're independent — it's the detail that's easiest to overlook when writing async code, and the one that hits performance hardest.