The cure for callback hell — and a cleaner way to handle things that take time.
In the last article, we talked about callbacks and saw how they can spiral into deeply nested, hard-to-read code — the infamous "Pyramid of Doom." If you've ever looked at five levels of indented callbacks and thought "there has to be a better way," you're right. There is.
Promises are that better way. They take the same asynchronous operations — fetching data, reading files, waiting for timers — and give you a flat, readable chain instead of a nested pyramid. Same results, dramatically better code.
When I first encountered Promises in the ChaiCode Web Dev Cohort 2026, I'll be honest — the concept felt abstract. "A future value?" "Pending, fulfilled, rejected?" But once I wrote a few .then() chains and compared them to the callback versions, the improvement was so obvious I never wanted to go back.
Let me show you.
What Problem Do Promises Solve?
Let's start with the pain. Here's a callback-based approach to a common pattern: fetch a user, then their orders, then shipping details.
// Callback version — the Pyramid of Doom
getUser(1, (err, user) => {
if (err) return console.log(err);
getOrders(user.id, (err, orders) => {
if (err) return console.log(err);
getShipping(orders[0].id, (err, shipping) => {
if (err) return console.log(err);
console.log(`Shipping to: ${shipping.address}`);
});
});
});
Problems: deep nesting, repeated error handling, hard to read, hard to maintain.
Now here's the same thing with Promises:
// Promise version — flat and readable
getUser(1)
.then((user) => getOrders(user.id))
.then((orders) => getShipping(orders[0].id))
.then((shipping) => console.log(`Shipping to: ${shipping.address}`))
.catch((err) => console.log(err));
Same steps. Same logic. But flat, linear, and with one catch handling all errors. That's the problem Promises solve.
What Is a Promise?
A Promise is an object that represents a value that might not be available yet but will be at some point in the future — or it might fail.
Think of it like ordering food online:
- You place the order → you get a receipt (the promise). The food isn't here yet, but you have a guarantee that it will arrive or you'll be notified of a problem.
- Your order is being prepared → the promise is pending.
- Food arrives → the promise is fulfilled (success!).
- Something goes wrong (restaurant closed, wrong address) → the promise is rejected (failure).
Online Order Analogy:
Place order → Pending... → Fulfilled (food arrives! 🍕)
OR
→ Rejected (order failed ❌)
In code, a Promise works the same way: it starts in a pending state, and eventually settles as either fulfilled (with a value) or rejected (with an error).
Promise States
Every Promise has exactly three possible states:
| State | Meaning | Settled? |
|---|---|---|
| Pending | Operation hasn't completed yet | ❌ No |
| Fulfilled | Operation succeeded — result is available | ✅ Yes |
| Rejected | Operation failed — error is available | ✅ Yes |
A Promise moves from pending to either fulfilled or rejected — never both, and never back to pending.
Promise Lifecycle Diagram
┌──────────────────────────────────────────────────┐
│ │
│ new Promise((resolve, reject) => { ... }) │
│ │
│ ┌─────────┐ │
│ │ PENDING │ │
│ └────┬────┘ │
│ │ │
│ ┌──────────┴──────────┐ │
│ ↓ ↓ │
│ ┌────────────┐ ┌───────────┐ │
│ │ FULFILLED │ │ REJECTED │ │
│ │ │ │ │ │
│ │ resolve() │ │ reject() │ │
│ │ called │ │ called │ │
│ └──────┬─────┘ └─────┬─────┘ │
│ ↓ ↓ │
│ .then() .catch() │
│ handles the handles the │
│ success value error reason │
│ │
└──────────────────────────────────────────────────┘
Once settled (fulfilled or rejected), a promise NEVER changes state again.
Creating a Basic Promise
Here's the syntax for creating a Promise from scratch:
const myPromise = new Promise((resolve, reject) => {
// Do some async work here...
// If successful:
resolve("Success! Here's your data.");
// If something goes wrong:
// reject("Something failed.");
});
-
resolve(value)— call this when the operation succeeds. The promise becomes fulfilled with this value. -
reject(reason)— call this when the operation fails. The promise becomes rejected with this reason.
Example: Simulating an API Call
const fetchUser = (userId) => {
return new Promise((resolve, reject) => {
console.log(`Fetching user ${userId}...`);
setTimeout(() => {
if (userId > 0) {
resolve({ id: userId, name: "Pratham", role: "developer" });
} else {
reject("Invalid user ID!");
}
}, 2000);
});
};
This function returns a Promise. After 2 seconds:
- If
userIdis valid → resolved with user data - If
userIdis invalid → rejected with an error message
Handling Success and Failure
.then() — Handle Fulfilled Promises
fetchUser(1).then((user) => {
console.log(`Got user: ${user.name}`);
});
// (after 2 seconds) "Got user: Pratham"
.then() receives the value that resolve() was called with.
.catch() — Handle Rejected Promises
fetchUser(-1).catch((error) => {
console.log(`Error: ${error}`);
});
// (after 2 seconds) "Error: Invalid user ID!"
.catch() receives the reason that reject() was called with.
Using Both Together
fetchUser(1)
.then((user) => {
console.log(`✅ User: ${user.name} (${user.role})`);
})
.catch((error) => {
console.log(`❌ Failed: ${error}`);
});
If the promise is fulfilled, .then() runs. If it's rejected, .catch() runs. Only one will execute — never both.
.finally() — Run No Matter What
fetchUser(1)
.then((user) => console.log(`User: ${user.name}`))
.catch((error) => console.log(`Error: ${error}`))
.finally(() => console.log("Request complete — cleanup done."));
.finally() runs whether the promise succeeded or failed. Perfect for cleanup tasks like hiding loading spinners.
The Promise Lifecycle in Action
Let me trace through the complete lifecycle with a real example:
console.log("1. Starting...");
const order = new Promise((resolve, reject) => {
console.log("2. Promise created — cooking pizza... 🍕");
setTimeout(() => {
const success = true;
if (success) {
resolve("Margherita Pizza");
} else {
reject("Kitchen is closed!");
}
}, 3000);
});
console.log("3. Doing other things while waiting...");
order
.then((pizza) => {
console.log(`4. Order fulfilled: ${pizza} delivered! 🎉`);
})
.catch((reason) => {
console.log(`4. Order rejected: ${reason} 😞`);
});
console.log("5. More synchronous code...");
Output:
1. Starting...
2. Promise created — cooking pizza... 🍕
3. Doing other things while waiting...
5. More synchronous code...
4. Order fulfilled: Margherita Pizza delivered! 🎉 ← 3 seconds later
Trace
Time 0ms:
"1. Starting..." — synchronous, runs immediately
Promise constructor runs — "2. Promise created..." — synchronous!
setTimeout registered (3-second timer starts)
"3. Doing other things..." — synchronous
.then() registered — "I'll call this function when the promise resolves"
"5. More synchronous code..." — synchronous
Time 3000ms:
Timer fires → resolve("Margherita Pizza") → promise is FULFILLED
.then() callback executes → "4. Order fulfilled: ..."
Key insight: The Promise constructor runs synchronously. Only the .then() and .catch() callbacks are deferred until the promise settles.
Promise Chaining
This is where Promises truly shine. Each .then() returns a new Promise, which means you can chain them — one after another, flat and readable.
How Chaining Works
const getUser = (id) =>
new Promise((resolve) => {
setTimeout(() => resolve({ id, name: "Pratham" }), 500);
});
const getOrders = (userId) =>
new Promise((resolve) => {
setTimeout(() => resolve([{ id: 101, item: "Laptop" }]), 500);
});
const getShipping = (orderId) =>
new Promise((resolve) => {
setTimeout(() => resolve({ address: "Delhi, India" }), 500);
});
// Chained — each .then() passes its result to the next
getUser(1)
.then((user) => {
console.log(`User: ${user.name}`);
return getOrders(user.id);
})
.then((orders) => {
console.log(`Order: ${orders[0].item}`);
return getShipping(orders[0].id);
})
.then((shipping) => {
console.log(`Ship to: ${shipping.address}`);
})
.catch((error) => {
console.log(`Error at any step: ${error}`);
});
// User: Pratham
// Order: Laptop
// Ship to: Delhi, India
Chaining Flow Visual
getUser(1)
│
↓ resolves with user
.then(user => getOrders(user.id))
│
↓ resolves with orders
.then(orders => getShipping(orders[0].id))
│
↓ resolves with shipping
.then(shipping => console.log(shipping.address))
If ANY step fails:
↓
.catch(error => handle it)
FLAT. LINEAR. ONE ERROR HANDLER.
Compare this to the nested callback version — same three steps, but without the pyramid:
Callbacks: Promises:
getUser(1, (err, user) => { getUser(1)
getOrders(user.id, (err, o) => { .then(user => getOrders(user.id))
getShipping(o[0].id, (err, s) => .then(orders => getShipping(...))
console.log(s.address) .then(ship => console.log(...))
}); .catch(err => console.log(err));
});
});
Nested, messy, error-prone Flat, clean, one catch
Callback vs Promise — The Comparison
| Feature | Callbacks | Promises |
|---|---|---|
| Readability | Nested → hard to follow | Flat chain → easy to read |
| Error handling | Every level needs if (err)
|
One .catch() for the entire chain |
| Composability | Difficult to combine |
.then() chains naturally |
| Return values | Can't return — must pass forward | Each .then() returns a new Promise |
| State tracking | No built-in state | Pending → Fulfilled / Rejected |
| Debugging | Hard to trace through nesting | Clear, linear flow |
Promises don't add new capabilities — everything you can do with Promises, you could technically do with callbacks. But Promises make the code dramatically cleaner, more maintainable, and less error-prone.
Common Mistakes
1. Forgetting to Return in .then()
// ❌ Wrong — missing return
getUser(1)
.then((user) => {
getOrders(user.id); // Not returned! Chain breaks here.
})
.then((orders) => {
console.log(orders); // undefined!
});
// ✅ Correct — return the Promise
getUser(1)
.then((user) => {
return getOrders(user.id);
})
.then((orders) => {
console.log(orders); // Works!
});
2. Nesting Promises (Defeating the Purpose)
// ❌ Don't do this — you're back to callback hell
getUser(1).then((user) => {
getOrders(user.id).then((orders) => {
getShipping(orders[0].id).then((shipping) => {
console.log(shipping.address);
});
});
});
// ✅ Do this — flat chain
getUser(1)
.then((user) => getOrders(user.id))
.then((orders) => getShipping(orders[0].id))
.then((shipping) => console.log(shipping.address))
.catch((err) => console.log(err));
3. Not Adding .catch()
// ❌ Unhandled rejection — will cause warnings/errors
getUser(-1).then((user) => console.log(user));
// ✅ Always handle errors
getUser(-1)
.then((user) => console.log(user))
.catch((err) => console.log("Handled:", err));
Let's Practice: Hands-On Assignment
Part 1: Create a Basic Promise
const coinFlip = new Promise((resolve, reject) => {
const result = Math.random() > 0.5;
setTimeout(() => {
if (result) {
resolve("Heads! You win! 🎉");
} else {
reject("Tails! You lose. 😅");
}
}, 1000);
});
coinFlip
.then((message) => console.log(message))
.catch((message) => console.log(message))
.finally(() => console.log("Game over."));
Part 2: Chain Promises
const step1 = () =>
new Promise((resolve) => {
setTimeout(() => {
console.log("Step 1: Data fetched");
resolve("raw data");
}, 1000);
});
const step2 = (data) =>
new Promise((resolve) => {
setTimeout(() => {
console.log(`Step 2: Processing ${data}`);
resolve("processed data");
}, 1000);
});
const step3 = (data) =>
new Promise((resolve) => {
setTimeout(() => {
console.log(`Step 3: Saving ${data}`);
resolve("saved successfully");
}, 1000);
});
step1()
.then((result) => step2(result))
.then((result) => step3(result))
.then((result) => console.log(`Done: ${result}`))
.catch((err) => console.log(`Error: ${err}`));
Part 3: Handle Errors in a Chain
const riskyOperation = (name) =>
new Promise((resolve, reject) => {
setTimeout(() => {
if (name.length > 0) {
resolve(`${name} processed successfully!`);
} else {
reject("Empty name — operation failed!");
}
}, 500);
});
// Successful
riskyOperation("Pratham")
.then((msg) => console.log(`✅ ${msg}`))
.catch((err) => console.log(`❌ ${err}`));
// Failed
riskyOperation("")
.then((msg) => console.log(`✅ ${msg}`))
.catch((err) => console.log(`❌ ${err}`));
Key Takeaways
-
Promises solve callback hell by replacing nested callbacks with flat, chainable
.then()calls and centralized error handling with.catch(). - A Promise has three states: pending (waiting), fulfilled (success via
resolve()), and rejected (failure viareject()). Once settled, it never changes. -
.then()handles success..catch()handles failure..finally()runs regardless. Together they cover every outcome. -
Promise chaining works because each
.then()returns a new Promise. Alwaysreturninside.then()to keep the chain going. - Promises don't add new capabilities over callbacks — they add structure, readability, and maintainability to async code.
Wrapping Up
Promises were the moment async JavaScript stopped being painful and started being elegant. The same operations, the same logic, but expressed in a way that reads top-to-bottom instead of nesting inward. And once you're comfortable with Promises, the next step — async/await — makes the syntax even cleaner by letting you write async code that looks synchronous.
I'm learning all of this through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. Promises were one of those topics where I went from "this is confusing" to "this is beautiful" in the span of a single lesson. If callbacks made sense to you, Promises will feel like a natural upgrade.
Connect with me on LinkedIn or visit PrathamDEV.in. More articles on the way.
Happy coding! 🚀
Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode
Top comments (0)