JavaScript Promises Explained for Beginners

If you have written JavaScript for any length of time, you have encountered situations where you need something to happen after something else finishes. Fetch this data, then display it.
Read this file, then process it. Wait for this response, then move on. The question is always the same: how do you write code that depends on something that has not happened yet?
The original answer was callbacks. The better answer is promises.
The Problem Promises Solve
A callback is a function you hand to another function and say: when you are done, call this. It works, but it has a flaw that becomes visible the moment one async operation depends on another.
Fetch a user. Then, using the user's ID, fetch their posts. Then, using the posts, fetch the comments on the first one. Each step depends on the last, so each callback nests inside the previous one:
fetchUser(userId, function(user) {
fetchPosts(user.id, function(posts) {
fetchComments(posts[0].id, function(comments) {
console.log(comments);
});
});
});
This is three levels deep with no error handling. Add error handling and a few more steps and the code shifts rightward until it is unreadable. The logic is buried inside indentation. The flow is hard to follow. Debugging is painful.
This is the problem promises solve. Not by making async code synchronous, but by giving it a shape that reads more like a sequence of steps than a nest of functions.
What a Promise Is
A promise is an object that represents a value that will be available at some point in the future. You cannot read the value immediately, but you have an object you can work with now, attach handlers to, and pass around. When the value eventually arrives, the promise delivers it to whoever is waiting.
The word promise is deliberate. When someone makes you a promise, they are not handing you something right now. They are committing to either deliver something or come back and explain why they could not. A JavaScript promise works the same way.
Promise States
Every promise exists in exactly one of three states at any given moment.
Pending is the starting state. The operation has been started but has not finished. The promise is waiting. No value is available yet.
Fulfilled means the operation completed successfully. The promised value is available. Any handlers attached for the success case will run.
Rejected means something went wrong. The operation failed. The promise carries a reason for the failure, usually an error object. Any handlers attached for the failure case will run.
Once a promise moves from pending to either fulfilled or rejected, it stays there. A fulfilled promise does not become rejected later. A rejected promise does not become fulfilled. The state change is permanent and happens exactly once.
This predictability is part of what makes promises easier to reason about than callbacks. You know that your success handler runs once if things go right, your failure handler runs once if things go wrong, and neither runs multiple times.
The Basic Promise Lifecycle
Creating a promise looks like this:
const myPromise = new Promise((resolve, reject) => {
const success = true;
if (success) {
resolve("Here is your value");
} else {
reject(new Error("Something went wrong"));
}
});
The Promise constructor takes a function called the executor. The executor runs immediately and receives two functions as arguments: resolve and reject. Call resolve with a value to fulfill the promise. Call reject with an error to reject it. The promise transitions from pending to whichever state you trigger.
In practice you rarely create promises manually. Libraries and built-in APIs return them for you. fetch returns a promise. Database clients return promises. File system functions return promises. You work with the promises they hand back rather than constructing your own.
Handling Success and Failure
A promise by itself does nothing useful until you attach handlers that tell it what to do when it settles.
.then() handles the fulfilled case. You pass it a function that receives the resolved value:
fetchUser(userId)
.then(function(user) {
console.log("Got user:", user.name);
});
.catch() handles the rejected case. You pass it a function that receives the rejection reason:
fetchUser(userId)
.then(function(user) {
console.log("Got user:", user.name);
})
.catch(function(error) {
console.log("Something failed:", error.message);
});
If fetchUser fulfills, the .then() handler runs and .catch() is skipped. If fetchUser rejects, the .then() handler is skipped and .catch() runs. You write both handlers and the promise delivers the result to the right one.
This is already an improvement over callbacks. The success path and the error path are visually separated. You do not handle errors inline inside nested functions. You attach a single .catch() and it covers the operation.
Promise Chaining
The real power of promises reveals itself when you need one async operation to follow another. This is where the readability improvement over callbacks becomes dramatic.
.then() returns a new promise. This means you can chain .then() calls, with each one receiving the value returned by the previous handler:
fetchUser(userId)
.then(function(user) {
return fetchPosts(user.id);
})
.then(function(posts) {
return fetchComments(posts[0].id);
})
.then(function(comments) {
console.log(comments);
})
.catch(function(error) {
console.log("Something failed:", error.message);
});
Compare this to the nested callback version from earlier. The logic is the same: fetch a user, fetch their posts, fetch comments on the first post. But now the steps read top to bottom like a sequence. Each .then() is one step. The indentation stays flat. The flow is clear.
The .catch() at the end covers every step. If fetchUser rejects, or fetchPosts rejects, or fetchComments rejects, the error falls through the chain to the single .catch() handler. You do not check for errors at every step. You handle them in one place.
One thing to be careful about in chains: always return the promise from inside a .then() handler. If you call fetchPosts without returning it, the next .then() does not wait for it to finish. The chain breaks. The value passed to the next handler will be undefined. Returning the promise is what connects the steps.
Promises vs Callbacks: A Direct Comparison
The same three-step async sequence written both ways shows the difference clearly.
With callbacks:
fetchUser(userId, function(userError, user) {
if (userError) {
handleError(userError);
return;
}
fetchPosts(user.id, function(postsError, posts) {
if (postsError) {
handleError(postsError);
return;
}
fetchComments(posts[0].id, function(commentsError, comments) {
if (commentsError) {
handleError(commentsError);
return;
}
console.log(comments);
});
});
});
With promises:
fetchUser(userId)
.then(user => fetchPosts(user.id))
.then(posts => fetchComments(posts[0].id))
.then(comments => console.log(comments))
.catch(error => handleError(error));
Same operations. One is eight lines of nested, manually error-checked code. The other is five lines that read like a numbered list of steps. The promise version is not just shorter. It is easier to read, easier to modify, and easier to reason about when something goes wrong.
Wrapping Up
Promises exist because async code written with callbacks becomes difficult to manage as soon as operations start depending on each other. Promises do not eliminate the asynchronous nature of the code. They give it a structure that reads clearly and handles errors consistently.
A promise starts pending, settles as fulfilled or rejected, and delivers its result to whichever handler you attached. Chains let you write sequences of async steps in a flat, readable style with a single error handler covering the whole thing.
This is the foundation. Everything that comes after, async/await, Promise.all, Promise.race, is built on top of the concepts covered here. Once the mental model of a future value with three states and two handlers is clear, the rest follows naturally.
You are all set!
written by theadroitdev(Shivam Verma) :)
X → https://x.com/theadroitdev
Github → https://github.com/TheAdroitDev
ChaiCode Repo → https://github.com/TheAdroitDev/ChaiCode-Cohort-2026

