Promises in JavaScript are like the friend who says they’ll help you move and shows up three hours late. In this article, we unpack promises, async/await, and how to stop chaining .then() like it’s a bad habit.
What Is a Promise?
A Promise is an object representing the eventual completion (or failure) of an asynchronous operation. It is always in one of three states:
- Pending — the operation hasn’t finished yet
- Fulfilled — the operation completed successfully
- Rejected — the operation failed
const promise = new Promise((resolve, reject) => {
setTimeout(() => resolve("done"), 1000);
});
promise.then((result) => console.log(result)); // "done" after 1 s
The .then() Chain
You can chain .then() calls to sequence async work. Each .then() receives the return value of the previous one.
fetch("/api/user")
.then((res) => res.json())
.then((user) => fetch(`/api/posts?userId=${user.id}`))
.then((res) => res.json())
.then((posts) => console.log(posts))
.catch((err) => console.error(err));
It works, but deeply nested chains get messy fast — hence async/await.
Enter async/await
async/await is syntactic sugar over promises. An async function always returns a promise; await pauses execution inside it until a promise settles.
async function loadUserPosts(userId) {
const res = await fetch(`/api/user/${userId}`);
const user = await res.json();
const postsRes = await fetch(`/api/posts?userId=${user.id}`);
return postsRes.json();
}
Reads like synchronous code. Runs asynchronously.
Error Handling
Wrap await calls in a try/catch block — it catches both synchronous throws and rejected promises.
async function loadData() {
try {
const res = await fetch("/api/data");
const data = await res.json();
return data;
} catch (err) {
console.error("Something went wrong:", err);
}
}
Avoid the common mistake of forgetting catch — unhandled rejections will surface as runtime warnings or errors depending on your environment.
Running Promises in Parallel
await inside a loop runs sequentially. If the tasks are independent, use Promise.all instead.
// Sequential — slow
for (const id of ids) {
await fetchPost(id);
}
// Parallel — fast
const posts = await Promise.all(ids.map((id) => fetchPost(id)));
Promise.allSettled is useful when you want all results regardless of whether some fail.
Common Pitfalls
Forgetting to await — the function returns a pending promise, not the resolved value.
async function bad() {
const data = fetch("/api"); // missing await — data is a Promise, not a Response
}
Mixing .then() and await in the same flow — pick one style per function and stick to it.
Takeaway
Promises are the foundation of async JavaScript. async/await makes them readable. Use Promise.all when you can run things in parallel, always handle rejections, and remember: the function never actually waits — it just looks like it does.