DEV Community

koushikmaya
koushikmaya

Posted on

JavaScript Promises Explained: Callback Hell, Promise States, Chaining & Error Propagation

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);
});
Enter fullscreen mode Exit fullscreen mode

Here:

  1. getUser() starts an asynchronous operation.
  2. The callback function is passed to getUser().
  3. After one second, the callback is executed.
  4. "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:

  1. Get a user
  2. Get that user's posts
  3. Get comments for those posts
  4. Get likes for those comments

Using callbacks, we might write:

getUser((user) => {
  getPosts(user, (posts) => {
    getComments(posts, (comments) => {
      getLikes(comments, (likes) => {
        console.log(likes);
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

We can write:

getUser()
  .then(getPosts)
  .then(getComments)
  .then(getLikes)
  .catch(handleError);
Enter fullscreen mode Exit fullscreen mode

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!");
});
Enter fullscreen mode Exit fullscreen mode

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");
  }
});
Enter fullscreen mode Exit fullscreen mode

4. Promise States

Every Promise has three possible states:

Pending
   |
   +----> Fulfilled
   |
   +----> Rejected
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example:

const promise = new Promise((resolve, reject) => {
  resolve("Success");
  reject("Error");
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

Output:

Hello
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

The output is:

30
Enter fullscreen mode Exit fullscreen mode

The flow is:

10
 ↓
10 × 2
 ↓
20
 ↓
20 + 10
 ↓
30
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

The flow is:

getUser()
   ↓
user
   ↓
getPosts(user)
   ↓
posts
   ↓
getComments(posts)
   ↓
comments
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

Output:

Network error
Enter fullscreen mode Exit fullscreen mode

A common pattern is:

fetchData()
  .then((data) => {
    console.log(data);
  })
  .catch((error) => {
    console.log("Error:", error);
  });
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

Output:

Something went wrong
Enter fullscreen mode Exit fullscreen mode

What happened?

.then()
   ↓
Error is thrown
   ↓
Promise becomes rejected
   ↓
Next .then() is skipped
   ↓
.catch() handles the error
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

The output is:

Start
Step 1
Failed!
Enter fullscreen mode Exit fullscreen mode

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");
  });
Enter fullscreen mode Exit fullscreen mode

The flow is:

Success → .then() → .finally()

Failure → .catch() → .finally()
Enter fullscreen mode Exit fullscreen mode

A common use case is a loading indicator.

setLoading(true);

fetchData()
  .then((data) => {
    displayData(data);
  })
  .catch((error) => {
    displayError(error);
  })
  .finally(() => {
    setLoading(false);
  });
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

Output:

Error
Recovered
Enter fullscreen mode Exit fullscreen mode

Why does the final .then() execute?

Because the .catch() returned "Recovered".

The flow is:

Rejected
   ↓
.catch()
   ↓
return "Recovered"
   ↓
Fulfilled
   ↓
.then()
Enter fullscreen mode Exit fullscreen mode

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");
  });
Enter fullscreen mode Exit fullscreen mode

The execution flow is approximately:

getUser()
   ↓
Pending
   ↓
Fulfilled
   ↓
.then(user)
   ↓
getPosts(user)
   ↓
Pending
   ↓
Fulfilled
   ↓
.then(posts)
   ↓
.catch() if something fails
   ↓
.finally()
Enter fullscreen mode Exit fullscreen mode

14. Callback Hell vs Promises

Callback version

getUser((user) => {
  getPosts(user, (posts) => {
    getComments(posts, (comments) => {
      getLikes(comments, (likes) => {
        console.log(likes);
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)