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);
});
});
});
Enter fullscreen mode Exit fullscreen mode
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);
});
Enter fullscreen mode Exit fullscreen mode
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");
});
Enter fullscreen mode Exit fullscreen mode
-
.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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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);
});
Enter fullscreen mode Exit fullscreen mode
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));
Enter fullscreen mode Exit fullscreen mode
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),
]);
Enter fullscreen mode Exit fullscreen mode
- 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);
}
});
Enter fullscreen mode Exit fullscreen mode
- 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)),
]);
Enter fullscreen mode Exit fullscreen mode
- 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(),
]);
Enter fullscreen mode Exit fullscreen mode
- 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"
Enter fullscreen mode Exit fullscreen mode
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 };
}
Enter fullscreen mode Exit fullscreen mode
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;
}
}
Enter fullscreen mode Exit fullscreen mode
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);
}
Enter fullscreen mode Exit fullscreen mode
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)]);
}
Enter fullscreen mode Exit fullscreen mode
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)));
}
Enter fullscreen mode Exit fullscreen mode
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);
}
}
Enter fullscreen mode Exit fullscreen mode
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);
}
}
Enter fullscreen mode Exit fullscreen mode
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 operationsPromise.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
Enter fullscreen mode Exit fullscreen mode
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.