JavaScript Promises Explained for Beginners
Also onpreet-jain.hashnode.dev/javascript-promises-for-beginnersJavaScript runs single-threaded, but the real world doesn't. You fetch data from an API, read files from disk, or wait for user input — none of that happens instantly. Promises exist to manage this asynchronous chaos without turning your code into a tangled mess.
The Callback Problem
Before promises, JavaScript handled async with callbacks. You pass a function to another function, and it gets called when something finishes. Simple enough in theory.
getUser(userId, (user) => {
getPosts(user.id, (posts) => {
getComments(posts[0].id, (comments) => {
console.log(comments);
});
});
});This is callback hell. Also called the pyramid of doom, for obvious reasons. Each nested callback depends on the previous one finishing, so you're forced into a vertical structure that gets hard to read and harder to maintain.
What makes it worse is error handling. With callbacks, errors typically get passed to the callback function itself. If something fails deep in this chain, you'd need to check for errors at every single level — and hope whoever wrote those callbacks was consistent about it.
Callback-style code also doesn't compose well. Want to run two async operations and combine results? That's awkward with callbacks. Need to add retry logic or timeout handling? You're writing that boilerplate yourself every time.
A Promise Is Just a Future Value
Think of a promise as an IOU. When you create one, you're saying "I'll have this value eventually." The promise will tell you when it's ready — whether it succeeded or failed.
Here's the same operation rewritten with promises:
getUser(userId)
.then(user => getPosts(user.id))
.then(posts => getComments(posts[0].id))
.then(comments => console.log(comments))
.catch(error => console.error('Something failed:', error));Flatter structure. Error handling in one place. The code reads almost like a series of steps written top-to-bottom.
Understanding Promise States
A promise can be in one of three states:
Pending — The async operation is still running. The promise hasn't decided what it is yet.
Fulfilled — The operation succeeded. The promise now has a value.
Rejected — The operation failed. The promise now knows why it failed.
Once a promise moves past pending, it stays there. You can't go from fulfilled back to pending, and you can't change the value. This matters because it means promises represent a one-way state transition — they resolve or reject exactly once.
Here's what creating a promise looks like:
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
const success = true;
if (success) {
resolve('Here is your data');
} else {
reject(new Error('Something went wrong'));
}
}, 1000);
});The Promise constructor takes a function with two parameters: resolve (call this when things work) and reject (call this when they don't).
Handling Success and Failure
There are two main ways to handle promise results: .then() and .catch().
promise
.then(value => {
console.log('Got value:', value);
})
.catch(error => {
console.error('Error:', error.message);
});The .then() method receives the value passed to resolve(). The .catch() method receives whatever was passed to reject(). What if the promise succeeds but you want to handle it differently depending on the value? .then() can take two functions:
promise.then(
value => console.log('Success:', value),
error => console.error('Failure:', error)
);This second parameter to .then() is equivalent to attaching a .catch(). Most developers prefer the explicit .catch() style because it's clearer about intent.
Chaining Promises
Here's where things get powerful. .then() returns a new promise. Whatever you return from a .then() handler becomes the resolved value of the next promise in the chain.
fetch('/user')
.then(response => response.json())
.then(user => fetch(`/posts/${user.id}`))
.then(response => response.json())
.then(posts => console.log(posts));Notice how each step can transform the value or kick off a new async operation. The result flows through the chain automatically.
If you return a promise from a .then() handler, the chain waits for it to resolve before continuing. This means you can build complex async flows without nesting:
fetch('/user')
.then(response => response.json())
.then(user => {
if (!user.isActive) {
return fetch('/default-posts');
}
return fetch(`/posts/${user.id}`);
})
.then(response => response.json())
.then(posts => renderPosts(posts));Conditional async logic without indentation nightmares.
The Lifecycle, Summarized
You create a promise or receive one from an async function
The promise starts in pending state
Something calls
resolve()orreject()The promise moves to fulfilled or rejected
Attached
.then()or.catch()handlers run with the result
That's the full picture. Everything else in promise-land builds on these basics.
Wrapping Up
Promises clean up async JavaScript by giving you a cleaner syntax and a standard way to handle success and failure. They reduce nesting, make error handling consistent, and compose naturally into chains that read top-to-bottom.
The mental shift from callbacks to promises takes a little practice. Once it clicks, you'll wonder how anyone managed without them. And then async/await came along and made it even nicer — but that's a topic for another day.