JavaScript Promises Explained for Beginners

So let me tell you that before Promises existed, JavaScript handled async stuff purely through callbacks only and callbacks will surely work but they have a very specific problem that anyone who has written enough of them has run into a loop hole of callbacks. Let me explain you more about the problem first, then we'll get into Promises.
The Callback Problem
Say you need to do three things in order fetch a user, then fetch their orders, then fetch the order details. Each step depends on the previous one finishing first.
With callbacks it looks like this:
getUser(userId, (err, user) => {
getOrders(user.id, (err, orders) => {
getOrderDetails(orders[0].id, (err, details) => {
console.log(details)
})
})
})
And this is only three levels deep. Add error handling, add more steps, and this keeps nesting further and further to the right. People call this callback hell and it's not just ugly, it's genuinely hard to debug and maintain. Promises were introduced to solve exactly this.
What is a Promise ?
A Promise is an object that represents a value you don't have yet, but will have at some point in the future.
When you order food at a restaurant and they give you a token, that token is basically a promise. You don't have the food yet, but you have a guarantee that either the food will arrive, or something went wrong and they'll let you know. You can go sit down, do other things, and wait for the outcome.
That's the mental model of a Promise that says "I don't have the result right now, but I'll get back to you."
The Three States of a Promise
Every Promise is always place in one of three states:
Pending: Pending is defined by the operation hasn't completed yet. This is the starting state. The promise is out there doing its thing.
Fulfilled: fulfilled is defined by the operation succeeded and you have the value. Everything went fine.
Rejected: rejected is defined by something that has went wrong. You get an error instead of a value.
A Promise moves from pending to either fulfilled or rejected and once it lands in one of those, it stays there. It doesn't flip back to pending or change again.
Creating a Basic Promise
const myPromise = new Promise((resolve, reject) => {
const success = true
if (success) {
resolve("Here's your data")
} else {
reject("Something went wrong")
}
})
resolve is what you call when the operation succeeds. reject is what you call when it fails. Whichever one gets called determines the final state of the promise.
You'll rarely create Promises manually like this in day-to-day code most libraries and built-in Node functions already return Promises. But knowing how they're built helps you understand what's happening under the hood.
Handling Success and Failure
Once you have a Promise, you handle the result using .then() and .catch().
myPromise
.then((result) => {
console.log(result)
})
.catch((error) => {
console.log(error)
})
.then() runs when the Promise is fulfilled. Whatever you passed to resolve() shows up as the argument here.
.catch() runs when the Promise is rejected. Whatever you passed to reject() shows up here.
If the Promise resolves, .catch() is skipped. If it rejects, .then() is skipped. Only one of them runs.
There's also .finally() which runs regardless of the outcome. Useful for cleanup stuff like hiding a loading spinner whether the request succeeded or failed.
myPromise
.then((result) => console.log(result))
.catch((error) => console.log(error))
.finally(() => console.log("done either way"))
Comparing Callbacks vs Promises
Here's the same operation written both ways.
With callbacks:
fetchUser(id, (err, user) => {
if (err) {
console.log("Error:", err)
return
}
console.log("User:", user)
})
With Promises:
fetchUser(id)
.then((user) => console.log("User:", user))
.catch((err) => console.log("Error:", err))
The logic is identical. But the Promise version reads top to bottom in a straight line. The callback version nests. For simple cases the difference is small but it compounds fast.
Promise Chaining
This is where Promises get genuinely useful. When you return something from a .then(), the next .then() in the chain receives that value.
fetchUser(id)
.then((user) => {
return fetchOrders(user.id)
})
.then((orders) => {
return fetchOrderDetails(orders[0].id)
})
.then((details) => {
console.log(details)
})
.catch((err) => {
console.log("Something failed:", err)
})
Compare this to the callback version from the beginning of this post. Same three async steps but now it reads like a straight list instead of a triangle pointing right.
And notice there's only one .catch() at the bottom. If anything in the chain fail at any step it jumps straight to that catch. You don't have to handle errors at every level separately. That's a big deal. Error handling in callback hell meant checking err at every single nested level. Promise chains let you handle it once at the bottom.
Example of Fetching Data
The fetch API in the browser returns a Promise. Here's how that looks:
fetch("https://api.example.com/users/1")
.then((response) => response.json())
.then((user) => {
console.log(user.name)
})
.catch((err) => {
console.log("Failed to fetch:", err)
})
First .then() converts the response to JSON which itself returns a Promise, so the chain continues. Second .then() gets the actual parsed data. If anything goes wrong at any point, .catch() handles it.
This is the pattern you'll see everywhere in JavaScript browser code, Node.js, any async operation really.




