Skip to main content

Command Palette

Search for a command to run...

JavaScript Promises Explained: From Callback Hell to Clean Async Code

Updated
8 min read
A
I create websites, I'm a weber!

When I first started learning async JavaScript, as a self-taught JS learner, I started with callbacks. I went straight into practicing, building, nesting one inside another. It worked. Until it didn’t.

At some point my code became impossible to follow. Functions inside functions inside functions. I wrote it myself and I still couldn’t trace where things were going wrong. I didn’t know it had a name — callback hell.

But then I thought — if there’s a problem, there must be a solution. That’s when I discovered Promises.

What is Callback Hell?

Before understanding Promises, you need to feel the problem they solve.

getUser(function(user) {
  getOrders(user.id, function(orders) {
    getOrderDetails(orders[0], function(details) {
      getPaymentInfo(details.id, function(payment) {
        // good luck debugging this
      });
    });
  });
});

Every async operation depends on the previous one. So you nest callbacks inside callbacks inside callbacks. It works — but it’s a nightmare to read, debug, and maintain. This is callback hell.

What is a Promise?

A Promise is an object that represents the result of an async operation.

When you make an API call, the Promise will either give you the data if it succeeds — or an error if it fails. It won’t keep you guessing. It won’t silently fail. It will always come back with a response and let you decide what to do next.

That’s why it’s called a Promise — it promises to return a result no matter what. Success or failure, you will always get an answer.

Real life analogy

Imagine you walk into a salon and ask the receptionist about manicure services. She doesn’t leave you sitting there wondering — she goes to check and comes back with an answer.

Think of the receptionist as a Promise. You asked her a question — now she’s gone to check. She will come back with an answer, yes or no. If the salon offers manicure services she’ll say yes and walk you through the options. If they don’t she’ll come back and tell you that too.

She won’t leave you sitting there wondering. She promised to return with an answer — and she will.

3 States of a Promise

Every Promise has three possible states:

  • Pending — the initial state. The async operation is still in progress.

  • Fulfilled — the operation completed successfully. Data is available.

  • Rejected — the operation failed. Error is available.

A Promise starts in pending and moves to either fulfilled or rejected depending on the result. Once it moves — it stays there. It can never change state again.

Creating a Promise

To create a Promise you use the Promise constructor:

const myPromise = new Promise((resolve, reject) => {
  if (success) {
    resolve(data); // fulfilled
  } else {
    reject(error); // rejected
  }
});

The constructor accepts two parameters — resolve and reject. If the operation succeeds you call resolve with the data. If it fails you call reject with the error.

Consuming a Promise

Creating a Promise is only half the story. You also need to handle the result. That’s where then, catch and finally come in:

fetchData()
  .then((data) => {
    // success — use the data
  })
  .catch((error) => {
    // failed — handle the error
  })
  .finally(() => {
    // runs always — success or failure
  });
  • then — runs when the Promise is fulfilled. Receives the data.

  • catch — runs when the Promise is rejected. Receives the error.

  • finally — runs no matter what. Useful for things like hiding a loading spinner.

Promise Chaining

This structure is called chaining. And you can take it further:

fetchUser()
  .then((user) => {
    return fetchOrders(user.id);
  })
  .then((orders) => {
    return fetchOrderDetails(orders[0]);
  })
  .then((details) => {
    console.log(details);
  })
  .catch((error) => {
    console.log(error);
  })
  .finally(() => {
    setLoading(false);
  });

When the first then runs successfully it passes the result to the next then. The second then manipulates that data and passes it along. This continues until you hit catch or finally.

Each then returns a new Promise — that’s why chaining works. Each step waits for the previous one to finish before running. One catch at the end handles errors from anywhere in the chain. One finally runs last no matter what.

But Wait — There’s Still a Problem

Promise chaining is clean. But as your async operations grow, even chaining can start to feel verbose. Look at this:

fetchUser()
  .then((user) => {
    return fetchOrders(user.id);
  })
  .then((orders) => {
    return fetchOrderDetails(orders[0]);
  })
  .then((details) => {
    console.log(details);
  })
  .catch((error) => {
    console.log(error);
  });

It’s better than callback hell — but it’s still a lot of chaining. What if there was a way to write async code that looks like normal synchronous code?

There is. It’s called async/await — and it’s built directly on top of Promises. Same concept, cleaner syntax.

That’s Part 3. For now, let’s understand the built-in Promise methods that make your async code even more powerful.

Promise Methods

JavaScript gives us built-in methods to handle common async patterns — so you don’t have to build them yourself. Here are the 4 most important ones.

Promise.all()

Real world scenario: You’re building a dashboard. Once the user logs in you need to fetch user details, configuration, and menu options — all at the same time, independently.

const [userDetails, config, menuOptions] = await Promise.all([
  fetchUserDetails(),
  fetchConfig(),
  fetchMenuOptions()
]);

Promise.all accepts an array of Promises and runs them concurrently. It waits for all of them to resolve and returns all results together. You don’t have to wait for one to finish before starting the next.

One important thing — if even one Promise rejects, Promise.all immediately rejects and you lose all results. Use it when all calls must succeed.

Promise.race()

Real world scenario: You’re working with data stored across multiple regional servers. When a user requests data you want to show it as fast as possible — so you call all servers simultaneously and use whichever responds first.

const data = await Promise.race([
  fetchFromUSServer(),
  fetchFromEUServer(),
  fetchFromAsiaServer()
]);

Promise.race accepts an array of Promises and returns the first one that settles — success or failure. It doesn’t care about the others once the first one finishes.

Important distinction — Promise.race doesn’t care how a Promise finishes. If the fastest Promise rejects, the entire race rejects immediately. It only cares about speed, not success.

Promise.any()

Real world scenario: You’re building a payment gateway. You call multiple payment processors simultaneously and use whichever one succeeds first.

const result = await Promise.any([
  processWithStripe(),
  processWithRazorpay(),
  processWithPayPal()
]);

Promise.any accepts an array of Promises and returns the first one that fulfills successfully. Unlike Promise.race it ignores rejections — if the first processor fails it moves on to the next successful one.

It only rejects if every single Promise in the array rejects.

Promise.race vs Promise.any:

  • Promise.race → first to finish, success or failure

  • Promise.any → first to succeed, ignores failures

This is the most common interview question about these two methods. Know the difference.

Promise.allSettled()

Real world scenario: You need to send emails to 100 users. One user has an invalid email address — but you don’t want that one failure to stop the other 99 emails from being sent.

const results = await Promise.allSettled([
  sendEmail(user1),
  sendEmail(user2),
  sendEmail(user3),
  // ... 100 users
]);

results.forEach((result) => {
  if (result.status === "fulfilled") {
    console.log("Email sent:", result.value);
  } else {
    console.log("Email failed:", result.reason);
  }
});

Promise.allSettled runs all Promises concurrently and returns the result of every single one — regardless of success or failure. It never rejects. You get a complete picture of what succeeded and what failed.

Use Promise.allSettled when you need to know the outcome of every Promise and can’t afford to stop on failure.

Quick Reference — When to Use Which

  • Promise.all → all must succeed, need all results together

  • Promise.race → need the fastest response, success or failure

  • Promise.any → need the first successful response, ignore failures

  • Promise.allSettled → need all outcomes, can’t stop on failure

Cheat Sheet

  • Pending → operation in progress

  • Fulfilled → success, data in then

  • Rejected → failed, error in catch

  • then() → handles success, returns a new Promise

  • catch() → handles any error in the chain

  • finally() → runs always, use for cleanup

  • Promise.all() → all or nothing

  • Promise.race() → fastest wins, success or failure

  • Promise.any() → first success wins

  • Promise.allSettled() → all results, no stopping

Part 2 is coming — output based interview questions on Promises that will actually test your understanding. Follow so you don’t miss it.

More JavaScript content — Medium

Happy Coding! 🚀

More from this blog

J

JS with ashaa

2 posts

Practical JavaScript concepts for developers preparing for frontend interviews. Covers closures, scope, async patterns, DSA, and more — with coding exercises to actually test your understanding.