Originally published at adityarawas.in
Asynchronous programming is one of the most important — and most misunderstood — parts of JavaScript. Whether you're fetching data from an API, reading files in Node.js, or querying a database, you're doing async work. Promises and async/await are the modern tools for handling it.
This guide takes you from the original callback problem all the way through Promise internals, chaining, error handling, parallel execution, and async/await — with practical patterns you can use immediately.
The Problem: Callback Hell
Before Promises, async operations were handled with callbacks — functions passed as arguments that get called when the operation completes.
getUserById(1, function(err, user) {
if (err) return handleError(err);
getPostsByUser(user.id, function(err, posts) {
if (err) return handleError(err);
getCommentsForPost(posts[0].id, function(err, comments) {
if (err) return handleError(err);
console.log(comments);
});
});
});
This pattern — called callback hell or the pyramid of doom — has real problems:
- Error handling must be repeated at every level
- Logic is deeply nested and hard to read
- Sequential async steps become tangled
- Adding retry logic or timeouts is painful
Promises were introduced in ES6 (2015) to solve this.
What is a Promise?
A Promise is an object representing the eventual completion or failure of an asynchronous operation. It acts as a placeholder for a value that doesn't exist yet.
A Promise has three possible states:
| State | Meaning |
|---|---|
| Pending | The async operation is still running |
| Fulfilled | The operation completed successfully (has a value) |
| Rejected | The operation failed (has a reason/error) |
Once a Promise is fulfilled or rejected, it is settled — it never changes state again.
Creating a Promise
const promise = new Promise((resolve, reject) => {
// Simulate an async operation
setTimeout(() => {
const success = true;
if (success) {
resolve("Data loaded"); // fulfills the promise
} else {
reject(new Error("Network failure")); // rejects the promise
}
}, 1000);
});
The Promise constructor takes an executor function that receives two callbacks:
-
resolve(value)— call this when the operation succeeds -
reject(reason)— call this when it fails
Consuming Promises with .then() and .catch()
promise
.then((value) => {
console.log(value); // "Data loaded"
})
.catch((error) => {
console.error(error.message); // "Network failure"
})
.finally(() => {
console.log("Done — runs regardless of outcome");
});
-
.then(onFulfilled)— runs when the promise resolves -
.catch(onRejected)— runs when the promise rejects -
.finally(callback)— runs in both cases (cleanup, hiding spinners, etc.)
Promise Chaining
The real power of Promises comes from chaining — each .then() returns a new Promise, so you can chain operations that depend on each other without nesting.
fetchUser(1)
.then((user) => fetchPosts(user.id)) // returns a new Promise
.then((posts) => fetchComments(posts[0].id))
.then((comments) => console.log(comments))
.catch((error) => console.error(error)); // catches any error in the chain
Compare this to the callback version above — the logic is flat, readable, and has a single error handler.
Returning Values in .then()
Whatever you return from a .then() callback becomes the resolved value of the next Promise in the chain:
Promise.resolve(5)
.then((n) => n * 2) // returns 10
.then((n) => n + 3) // returns 13
.then((n) => console.log(n)); // 13
If you return a Promise, the chain waits for it to settle before continuing.
Error Handling
A .catch() at the end of a chain catches any rejection from any step above it:
fetchUser(1)
.then((user) => {
if (!user.active) throw new Error("User is deactivated");
return fetchPosts(user.id);
})
.then((posts) => processPosts(posts))
.catch((error) => {
// Catches: network errors, thrown errors, or rejected promises
console.error("Something went wrong:", error.message);
});
You can also place .catch() in the middle of a chain to recover from errors and continue:
fetchUser(1)
.catch(() => getGuestUser()) // fallback if fetchUser fails
.then((user) => renderProfile(user));
Always Handle Rejections
Unhandled promise rejections crash Node.js processes (since Node 15+) and log warnings in browsers. Always add a .catch() or use try/catch with async/await.
Running Promises in Parallel
When you have multiple independent async operations, run them in parallel instead of sequentially.
Promise.all — All Must Succeed
const [user, posts, settings] = await Promise.all([
fetchUser(1),
fetchPosts(1),
fetchSettings(1),
]);
- Runs all three in parallel — total time ≈ slowest single request
- If any promise rejects, the whole
Promise.allrejects immediately - Use when you need all results and any failure should stop everything
Promise.allSettled — Collect All Results
const results = await Promise.allSettled([
fetchUser(1),
fetchPosts(1),
fetchSettings(1),
]);
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log(result.value);
} else {
console.error(result.reason);
}
});
- Waits for all promises to settle regardless of success or failure
- Use when you want all results and partial failures are acceptable
Promise.race — First One Wins
const result = await Promise.race([
fetchData(),
new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 5000)),
]);
- Resolves or rejects as soon as the first promise settles
- Useful for implementing timeouts
Promise.any — First Success Wins
const result = await Promise.any([
fetchFromServer1(),
fetchFromServer2(),
fetchFromServer3(),
]);
- Resolves with the first fulfilled promise
- Only rejects if all promises reject (throws
AggregateError) - Useful for trying multiple sources and taking whichever responds first
Async/Await
Async/await is syntax sugar over Promises — it doesn't replace them, it makes them look like synchronous code.
The async keyword
Adding async before a function makes it always return a Promise:
async function greet() {
return "Hello"; // automatically wrapped in Promise.resolve("Hello")
}
greet().then(console.log); // "Hello"
The await keyword
await pauses execution inside an async function until the Promise settles:
async function loadUserData() {
const user = await fetchUser(1); // waits for this to resolve
const posts = await fetchPosts(user.id); // then waits for this
return { user, posts };
}
This is equivalent to the .then() chain, but reads like synchronous code.
Error Handling with try/catch
async function loadUserData() {
try {
const user = await fetchUser(1);
const posts = await fetchPosts(user.id);
return { user, posts };
} catch (error) {
console.error("Failed to load data:", error.message);
return null;
}
}
Common Async/Await Mistakes
Mistake 1: Forgetting await
// Wrong — `user` is a Promise, not the resolved value
async function getUser() {
const user = fetchUser(1); // missing await
console.log(user.name); // undefined
}
// Correct
async function getUser() {
const user = await fetchUser(1);
console.log(user.name);
}
Mistake 2: Sequential await When Parallel is Possible
// Slow — runs one after the other (sum of both delays)
async function slow() {
const user = await fetchUser(1);
const posts = await fetchPosts(1);
}
// Fast — runs in parallel (max of both delays)
async function fast() {
const [user, posts] = await Promise.all([fetchUser(1), fetchPosts(1)]);
}
This is the single most common performance bug in async code. If two operations don't depend on each other, always parallelize with Promise.all.
Mistake 3: await Inside .forEach()
// Wrong — forEach doesn't wait for async callbacks
async function processAll(ids) {
ids.forEach(async (id) => {
await processItem(id); // these all fire simultaneously, forEach returns immediately
});
}
// Correct — sequential with for...of
async function processAll(ids) {
for (const id of ids) {
await processItem(id);
}
}
// Correct — parallel with Promise.all
async function processAll(ids) {
await Promise.all(ids.map((id) => processItem(id)));
}
Mistake 4: Unhandled Rejections
// Dangerous — if fetchData() rejects, the error is silently swallowed
async function load() {
const data = await fetchData(); // no try/catch
}
// Safe
async function load() {
try {
const data = await fetchData();
} catch (err) {
handleError(err);
}
}
Real-World Example: Fetching API Data
Here's a complete pattern for fetching data with proper error handling and loading states:
async function fetchUserProfile(userId) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const user = await response.json();
return user;
} catch (error) {
if (error.name === "AbortError") {
throw new Error("Request timed out");
}
throw error;
} finally {
clearTimeout(timeout);
}
}
Promises vs Async/Await: When to Use Which
| Situation | Recommendation |
|---|---|
| Simple single operation | Either — async/await is more readable |
| Chaining multiple operations | Async/await |
| Parallel operations |
Promise.all / Promise.allSettled
|
| Utility functions returning promises | Return a Promise directly |
| Event handlers | Async/await with try/catch |
Legacy .then() chains you're refactoring |
Async/await |
In practice, most modern codebases use async/await for control flow and Promise.all for parallelism, with .catch() reserved for one-liners.
How Promises Fit in the Event Loop
Understanding where Promises sit in the event loop explains some subtle timing behaviors:
- Promise callbacks (
.then(),.catch()) are scheduled as microtasks - Microtasks run before the next macrotask (setTimeout, setInterval, I/O)
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// Output: 1, 4, 3, 2
Even though setTimeout has a delay of 0ms, the Promise microtask (3) runs before it (2). This is why deeply nested Promise chains can occasionally starve the macrotask queue.
For a deeper look at the event loop phases — timers, I/O, and libuv — see Is Node.js Single-Threaded or Multi-Threaded?.
Key Takeaways
- A Promise is an object that represents a value available now, in the future, or never — in one of three states: pending, fulfilled, or rejected.
-
Promise chaining with
.then()flattens nested async logic into readable sequential steps. -
Promise.allruns operations in parallel and fails fast;Promise.allSettledcollects all results regardless of failure. -
async/awaitis syntax sugar over Promises — it makes async code look synchronous without blocking the thread. - The most common async bug is sequential
awaitwherePromise.allshould be used. - Promise callbacks run as microtasks — before
setTimeoutand other macrotasks. - Always handle rejections with
.catch()ortry/catchto avoid unhandled rejection crashes.
Top comments (1)
"The most common async bug is sequential await where Promise.all should be used" is the line I'd have put in the title. Most of the rest of this material is known by anyone who has shipped Node for a year; that one specific shape of wrong is what actually shows up in reviews — a loop of
awaits that works fine on three items and triples your latency on three hundred.Two additions I'd make to your list.
Promise.allSettledgives you the outcome but not the reason ordering: if one of five calls fails you still have to know which slot died, and the index is easy to lose once you map over results. And an unhandled rejection is not always loud — the failure I debugged longest was a promise that never settled at all, so neither the success nor the rejection path ran and the await above it just hung quietly. Did you run into that one yet, or only the classic crash-on-rejection kind?