Asynchronous JavaScript is one of the most important concepts to
understand when building modern web applications.
At first, asynchronous code is often written using callbacks. But when
multiple asynchronous operations depend on each other, the code can
become difficult to read and maintain. This problem is commonly known as
callback hell.
JavaScript Promises provide a cleaner way to handle asynchronous
operations using .then(), .catch(), and .finally().
In this article, we'll understand:
- Callback hell
- What a Promise is
- Promise states: pending, fulfilled, and rejected
-
.then() -
.catch() -
.finally() - Promise chaining
- Error propagation
- Error recovery
1. What is a Callback?
A callback is a function that is passed to another function and executed
later, usually after an operation has completed.
function getUser(callback) {
setTimeout(() => {
callback("User data");
}, 1000);
}
getUser((data) => {
console.log(data);
});
Here:
-
getUser()starts an asynchronous operation. - The callback function is passed to
getUser(). - After one second, the callback is executed.
-
"User data"is received by the callback.
Callbacks work well for simple operations, but problems appear when we
have many dependent asynchronous operations.
2. Callback Hell
Imagine that we need to:
- Get a user
- Get that user's posts
- Get comments for those posts
- Get likes for those comments
Using callbacks, we might write:
getUser((user) => {
getPosts(user, (posts) => {
getComments(posts, (comments) => {
getLikes(comments, (likes) => {
console.log(likes);
});
});
});
});
Notice how the functions keep getting nested inside one another.
This is called callback hell, also known as the Pyramid of Doom.
Why is callback hell a problem?
Deeply nested callbacks can make code:
- Difficult to read
- Difficult to debug
- Difficult to maintain
- Difficult to handle errors in
- Harder to extend when more asynchronous operations are added
Promises provide a cleaner alternative.
Instead of:
getUser((user) => {
getPosts(user, (posts) => {
getComments(posts, (comments) => {
getLikes(comments, (likes) => {
console.log(likes);
});
});
});
});
We can write:
getUser()
.then(getPosts)
.then(getComments)
.then(getLikes)
.catch(handleError);
The flow becomes much easier to follow.
3. What is a Promise?
A Promise is an object that represents the eventual result of an
asynchronous operation.
A Promise can either:
- Complete successfully
- Fail
For example:
const promise = new Promise((resolve, reject) => {
// asynchronous operation
resolve("Success!");
});
A Promise receives two functions:
-
resolve()--- used when the operation succeeds -
reject()--- used when the operation fails
For example:
const promise = new Promise((resolve, reject) => {
const success = true;
if (success) {
resolve("Operation successful");
} else {
reject("Operation failed");
}
});
4. Promise States
Every Promise has three possible states:
Pending
|
+----> Fulfilled
|
+----> Rejected
4.1 Pending
A Promise starts in the pending state.
It means the asynchronous operation has not completed yet.
const promise = new Promise((resolve, reject) => {
// Operation is still running
});
At this point, the Promise is pending.
4.2 Fulfilled
If the operation completes successfully, we call resolve().
const promise = new Promise((resolve, reject) => {
resolve("Data received");
});
The Promise is now fulfilled.
The successful result can be handled using .then().
4.3 Rejected
If something goes wrong, we call reject().
const promise = new Promise((resolve, reject) => {
reject("Something went wrong");
});
The Promise is now rejected.
The error can be handled using .catch().
Important: A Promise settles only once
A Promise starts as pending and eventually becomes either fulfilled or
rejected.
Once it is settled, its state cannot be changed again.
Fulfilled
/
Pending ----
\
Rejected
For example:
const promise = new Promise((resolve, reject) => {
resolve("Success");
reject("Error");
});
The rejection will not change the result because the Promise has already
been fulfilled.
5. .then()
The .then() method is used to handle a successful Promise result.
Promise.resolve("Hello")
.then((result) => {
console.log(result);
});
Output:
Hello
The value passed to resolve() becomes the value received by .then().
const promise = new Promise((resolve) => {
resolve("User data");
});
promise.then((data) => {
console.log(data);
});
6. Promise Chaining
One of the biggest advantages of Promises is chaining.
We can attach multiple .then() calls:
Promise.resolve(10)
.then((num) => {
return num * 2;
})
.then((num) => {
return num + 10;
})
.then((result) => {
console.log(result);
});
The output is:
30
The flow is:
10
↓
10 × 2
↓
20
↓
20 + 10
↓
30
The important rule
Whatever you return from one .then() becomes the input to the next
.then().
For example:
Promise.resolve(5)
.then((value) => {
return value * 2;
})
.then((value) => {
console.log(value);
});
The first .then() returns 10, so the second .then() receives 10.
7. Chaining Asynchronous Operations
Promises become especially useful when one asynchronous operation
depends on another.
For example:
getUser()
.then((user) => {
return getPosts(user);
})
.then((posts) => {
return getComments(posts);
})
.then((comments) => {
console.log(comments);
})
.catch((error) => {
console.log(error);
});
The flow is:
getUser()
↓
user
↓
getPosts(user)
↓
posts
↓
getComments(posts)
↓
comments
Each operation returns a Promise, and the next .then() waits for that
Promise to settle successfully.
8. .catch()
.catch() is used to handle rejected Promises and errors that occur in
the Promise chain.
Promise.reject("Network error")
.catch((error) => {
console.log(error);
});
Output:
Network error
A common pattern is:
fetchData()
.then((data) => {
console.log(data);
})
.catch((error) => {
console.log("Error:", error);
});
If fetchData() fails, the .catch() handles the error.
9. Error Propagation
This is one of the most important Promise concepts.
Suppose an error occurs inside a .then():
Promise.resolve()
.then(() => {
throw new Error("Something went wrong");
})
.then(() => {
console.log("This will not execute");
})
.catch((error) => {
console.log(error.message);
});
Output:
Something went wrong
What happened?
.then()
↓
Error is thrown
↓
Promise becomes rejected
↓
Next .then() is skipped
↓
.catch() handles the error
An error can therefore propagate through the Promise chain until a
.catch() handler is found.
10. Error Propagation Through Multiple .then() Calls
Consider:
Promise.resolve("Start")
.then((value) => {
console.log(value);
return "Step 1";
})
.then((value) => {
console.log(value);
throw new Error("Failed!");
})
.then(() => {
console.log("This won't run");
})
.then(() => {
console.log("This won't run either");
})
.catch((error) => {
console.log(error.message);
});
The output is:
Start
Step 1
Failed!
Once the error is thrown, the remaining .then() handlers are skipped
until .catch() is reached.
This is why we can put a single .catch() at the end of a Promise chain
to handle errors from multiple operations.
11. .finally()
.finally() runs regardless of whether the Promise is fulfilled or
rejected.
fetchData()
.then((data) => {
console.log(data);
})
.catch((error) => {
console.log(error);
})
.finally(() => {
console.log("Operation finished");
});
The flow is:
Success → .then() → .finally()
Failure → .catch() → .finally()
A common use case is a loading indicator.
setLoading(true);
fetchData()
.then((data) => {
displayData(data);
})
.catch((error) => {
displayError(error);
})
.finally(() => {
setLoading(false);
});
Whether the request succeeds or fails, the loading indicator is turned
off.
12. .catch() Can Recover From an Error
An interesting feature of Promise chains is that .catch() can return a
value.
Promise.reject("Error")
.catch((error) => {
console.log(error);
return "Recovered";
})
.then((value) => {
console.log(value);
});
Output:
Error
Recovered
Why does the final .then() execute?
Because the .catch() returned "Recovered".
The flow is:
Rejected
↓
.catch()
↓
return "Recovered"
↓
Fulfilled
↓
.then()
So a .catch() can handle an error and allow the Promise chain to
continue.
13. Complete Example
Let's combine all the concepts.
function getUser() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
id: 1,
name: "Koushik"
});
}, 1000);
});
}
function getPosts(user) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(["Post 1", "Post 2"]);
}, 1000);
});
}
getUser()
.then((user) => {
console.log("User:", user);
return getPosts(user);
})
.then((posts) => {
console.log("Posts:", posts);
})
.catch((error) => {
console.log("Error:", error);
})
.finally(() => {
console.log("Finished");
});
The execution flow is approximately:
getUser()
↓
Pending
↓
Fulfilled
↓
.then(user)
↓
getPosts(user)
↓
Pending
↓
Fulfilled
↓
.then(posts)
↓
.catch() if something fails
↓
.finally()
14. Callback Hell vs Promises
Callback version
getUser((user) => {
getPosts(user, (posts) => {
getComments(posts, (comments) => {
getLikes(comments, (likes) => {
console.log(likes);
});
});
});
});
Promise version
getUser()
.then((user) => getPosts(user))
.then((posts) => getComments(posts))
.then((comments) => getLikes(comments))
.then((likes) => {
console.log(likes);
})
.catch((error) => {
console.log(error);
});
The Promise version is flatter, easier to read, and provides a
centralized error-handling mechanism.
15. Quick Summary
Concept Meaning
Callback Function executed later
Callback Hell Deeply nested callbacks
Promise Represents the eventual result of an async operation
Pending Operation is still in progress
Fulfilled Operation completed successfully
Rejected Operation failed
.then() Handles successful results
.catch() Handles errors
.finally() Runs regardless of success or failure
Promise chaining Connecting multiple asynchronous operations
Error propagation Errors move through the chain until handled
The most important flow to remember is:
┌──→ Fulfilled → .then()
│
Pending ─────┤
│
└──→ Rejected → .catch()
↓
.finally()
And the most important Promise chaining rule is:
The value returned from one
.then()becomes the input to the next
.then().
Final Takeaway
Callbacks are useful for handling asynchronous operations, but deeply
nested callbacks can lead to callback hell.
Promises provide a structured way to manage asynchronous operations. A
Promise starts as pending and eventually becomes either
fulfilled or rejected.
- Use
.then()for successful results. - Use
.catch()for errors. - Use
.finally()for code that should run regardless of the result. - Use chaining to organize multiple asynchronous operations.
- Errors thrown anywhere in a chain can propagate to
.catch().
Understanding these concepts gives you the foundation for learning
async/await, which provides another cleaner way to work with
Promises.
Top comments (0)