Introduction
Asynchronous code trips up almost every JavaScript developer at some point — callback hell, unhandled rejections, race conditions. Promises and async/await exist to solve exactly this, but most tutorials skip why they work, not just how to use them.
In this guide, we'll cover:
- What a Promise actually is
- Creating and chaining Promises
async/awaitsyntax- Error handling
- Running multiple Promises in parallel
- Common mistakes to avoid
Every example is something you'll actually write in real code.
1. What a Promise Actually Is
A Promise is an object representing a value that isn't available yet. It has three states: pending, fulfilled, or rejected — and once it settles, it never changes state again.
const promise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
resolve("Data loaded");
} else {
reject(new Error("Failed to load data"));
}
}, 1000);
});
console.log(promise); // Promise { <pending> }
resolve and reject are functions you call to settle the promise. Whatever you pass to them becomes the value that consumers receive.
2. Creating and Chaining Promises
Consume a Promise with .then() and .catch():
function fetchUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id <= 0) {
reject(new Error("Invalid user ID"));
return;
}
resolve({ id, name: "Ali Khan" });
}, 500);
});
}
fetchUser(1)
.then((user) => {
console.log(user.name);
return fetchUser(2); // chain another promise
})
.then((user) => console.log(user.name))
.catch((error) => console.error(error.message));
Each .then() returns a new Promise, which is what makes chaining work. If you return a value, the next .then() receives it. If you return a Promise, the chain waits for it to settle first.
3. async/await Syntax
async/await is syntax sugar over Promises — it lets you write asynchronous code that reads like synchronous code:
async function getUserName(id) {
const user = await fetchUser(id);
return user.name;
}
getUserName(1).then((name) => console.log(name));
Two rules to remember:
1. `await` can only be used inside a function marked `async`.
2. An `async` function always returns a Promise — even if you
`return` a plain value, it gets wrapped automatically.
Compare the same logic written both ways:
// Promise chain
function loadDashboard(userId) {
return fetchUser(userId)
.then((user) => fetchOrders(user.id))
.then((orders) => fetchTotal(orders))
.catch((err) => console.error(err));
}
// async/await — same behavior, easier to read
async function loadDashboard(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
const total = await fetchTotal(orders);
return total;
} catch (err) {
console.error(err);
}
}
4. Error Handling
With Promises, errors propagate down the chain until a .catch() handles them. With async/await, use a standard try/catch block:
async function safeFetch(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Fetch failed:", error.message);
return null;
}
}
A common mistake is forgetting that a rejected Promise without a .catch() (or try/catch) crashes Node.js processes and logs unhandled rejection warnings in browsers. Always handle errors at the boundary where the async call happens.
5. Running Multiple Promises in Parallel
Awaiting Promises one by one is slow when they don't depend on each other. Use Promise.all() to run them concurrently:
async function loadAllData() {
const [users, products, orders] = await Promise.all([
fetchUsers(),
fetchProducts(),
fetchOrders(),
]);
return { users, products, orders };
}
| Method | Behavior |
|---|---|
Promise.all() |
Resolves when all succeed; rejects immediately if any one fails |
Promise.allSettled() |
Waits for all to finish, success or failure, and reports each result |
Promise.race() |
Settles as soon as the first Promise settles, win or lose |
Promise.any() |
Resolves as soon as the first one succeeds; ignores failures unless all fail |
Use Promise.allSettled() when you want results even if some requests fail:
async function loadOptionalData() {
const results = await Promise.allSettled([
fetchUsers(),
fetchAnalytics(), // might fail, that's ok
]);
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log("Got:", result.value);
} else {
console.warn("Failed:", result.reason.message);
}
});
}
6. Common Mistakes to Avoid
Mistake 1 — awaiting in a loop when requests are independent:
// Slow — each request waits for the previous one
for (const id of userIds) {
const user = await fetchUser(id);
console.log(user.name);
}
// Fast — all requests fire at once
const users = await Promise.all(userIds.map((id) => fetchUser(id)));
users.forEach((user) => console.log(user.name));
Mistake 2 — forgetting that forEach doesn't wait for async callbacks:
// Wrong — forEach doesn't await, all logs fire before data loads
userIds.forEach(async (id) => {
const user = await fetchUser(id);
console.log(user.name);
});
// Right — use a for...of loop or Promise.all instead
for (const id of userIds) {
const user = await fetchUser(id);
console.log(user.name);
}
Mistake 3 — mixing .then() and await unnecessarily. Pick one style per function — mixing them makes control flow harder to follow.
Putting It All Together
| Concept | Use it for |
|---|---|
new Promise() |
Wrapping callback-based or timer-based async work |
.then() / .catch() |
Chaining async steps the traditional way |
async/await |
Writing async code that reads top-to-bottom |
try/catch |
Catching errors in async functions |
Promise.all() |
Running independent async tasks in parallel |
Promise.allSettled() |
Parallel tasks where some failures are expected |
Promises aren't magic — they're just objects that track a future value and notify you when it's ready. async/await doesn't replace Promises, it just makes them easier to read. Once that clicks, async JavaScript stops being scary and starts being just another tool you reach for without thinking twice.