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.