Skip to main content

Command Palette

Search for a command to run...

JavaScript Promises Explained for Beginners

Published
7 min read
JavaScript Promises Explained for Beginners

A Promise is an object that represents a value you don't have yet — but will have at some point in the future. Instead of blocking your code waiting for that value, JavaScript hands you the Promise immediately and lets you describe what to do when it resolves.

This makes it easier to handle tasks like API calls, file operations, and timers.

Before Promises existed, JavaScript developers handled async operations with callbacks. Promises didn't just offer a cleaner syntax, they solved real structural problems that callbacks couldn't.

1. Why Promises Were Introduced

Before promises:

  • Code became messy with nested callbacks

  • Error handling was difficult

  • Readability suffered

Let's understand with an example :

getUser(userId, function(err, user) {
  if (err) return console.error(err);

  getOrders(user.id, function(err, orders) {
    if (err) return console.error(err);

    getOrderDetails(orders[0].id, function(err, details) {
      if (err) return console.error(err);

      console.log("Details:", details);
    });
  });
});

In above operation, therer are three operations. Already three levels of nesting. Each level has its own error check. Adding one more step means another level of indentation. This is the pyramid of doom.

It has three specific problems Promises are designed to fix:

Error handling is repetitive : Every single callback needs its own if (err) check. Miss one and errors silently disappear.

Flow is hard to follow : Logic flows diagonally, not top to bottom. Reading this requires tracking indentation levels instead of reading normally.

Reusability is poor : These nested callbacks are tightly coupled. Pulling one piece out to reuse it elsewhere is painful.

Now here's the same logic with Promises:

getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => console.log("Details:", details))
  .catch(err => console.error(err));

One error handler at the end covers everything. The flow reads top to bottom. Each step is one clean line. This is the core promise of Promises, flattening the pyramid into a readable chain.

2. Promise States

Every Promise exists in exactly one of the three states at any moment. Understanding these states is understanding how Promises work.

Pending : the initial state. The operation has started but hasn't finished. Like your package that's been shipped but not delivered.

Fulfilled : the operation completed successfully. The Promise now has a resolved value. Your package arrived.

Rejected : the operation failed. The Promise has a reason for failure (an error). Delivery failed.

// A Promise starts pending
let myPromise = new Promise(function(resolve, reject) {
  setTimeout(function() {
    resolve("Package delivered!");  // moves to fulfilled
    // or: reject(new Error("Delivery failed")); // moves to rejected
  }, 2000);
});

console.log(myPromise);  // Promise { <pending> } — checked too early

Two rules about states worth remembering:

States are final once settled. Once a Promise fulfills or rejects, it cannot change. A fulfilled Promise stays fulfilled forever. You can attach handlers to it even after it's resolved, you'll still get the value.

A settled Promise is either fulfilled or rejected, never both. Only resolve or reject is ever called, and only the first call matters:

let p = new Promise((resolve, reject) => {
  resolve("first");   // this one counts
  reject("second");   // ignored — Promise already settled
  resolve("third");   // also ignored
});

p.then(val => console.log(val));  // "first"

3. Basic Promise Lifecycle

Now let's look at how you create and consume Promises.

Creating a Promise

You create a Promise with new Promise(), passing it an executor function. The executor receives two arguments — resolve and reject — which you call depending on whether the operation succeeded or failed:

js

let fetchWeather = new Promise(function(resolve, reject) {
  // Simulating an API call with a timer
  setTimeout(function() {
    let success = true;

    if (success) {
      resolve({ city: "Indore", temp: 32, condition: "Sunny" });
    } else {
      reject(new Error("Weather service unavailable"));
    }
  }, 1500);
});

The executor runs immediately when the Promise is created. The async work happens inside it. When the work completes, call resolve with the result, or reject with an error.

Consuming a Promise

Once you have a Promise, you attach handlers to it using .then() and .catch():

fetchWeather
  .then(function(data) {
    console.log(`\({data.city}: \){data.temp}°C, ${data.condition}`);
    // "Indore: 32°C, Sunny"
  })
  .catch(function(err) {
    console.error("Error:", err.message);
  });

.then() receives the resolved value. .catch() receives the rejection reason. Neither is called synchronously, they're always called asynchronously, after the current synchronous code finishes.

Promise Shorthand

For cases where you already have a value (or a known error), you don't need to construct a full Promise:

// Already have a value — resolve immediately
let resolved = Promise.resolve(42);
resolved.then(val => console.log(val));  // 42

// Already know it failed
let rejected = Promise.reject(new Error("Known failure"));
rejected.catch(err => console.log(err.message));  // "Known failure"

4. Handling Success and Failure

.then() and .catch() are your main tools for responding to a Promise's outcome. Let's look at them in depth.

.then(onFulfilled, onRejected) :

.then() actually accepts two arguments, a success handler and an optional failure handler:

fetchUser(42).then(
  function(user) { console.log("Got user:", user.name); },   // success
  function(err)  { console.log("Failed:", err.message); }    // failure
);

In practice, most developers use .catch() for the failure case instead — it's cleaner and handles errors from anywhere in the chain. But knowing .then() accepts both is useful context.

.catch(): Centralized Error Handling

.catch(handler) is shorthand for .then(null, handler). It catches rejections from the Promise it's attached to, or from any .then() earlier in the chain:

fetchUserProfile(userId)
  .then(profile => {
    if (!profile.isActive) throw new Error("Account suspended");
    return profile;
  })
  .then(profile => displayProfile(profile))
  .catch(err => {
    // Catches: fetchUserProfile rejection OR the "Account suspended" throw
    showErrorBanner(err.message);
  });

Notice the throw inside .then() — throwing inside a .then() handler automatically converts the chain into a rejection, which flows down to .catch(). This is one of the most useful features of the Promise chain.

.finally() : Always Runs

Just like try/catch/finally, Promises have .finally() — a handler that runs regardless of outcome:

showSpinner();

fetchDashboardData()
  .then(data => renderDashboard(data))
  .catch(err => showErrorMessage(err.message))
  .finally(() => hideSpinner());  // always called

.finally() doesn't receive any argument, it has no way of knowing whether the Promise fulfilled or rejected, and intentionally so. It's only for cleanup.

5. Promise Chaining

The Key Rule: .then() always returns a new Promise. Whatever you return from a .then() handler, becomes the resolved value of that new Promise, which the next .then() in the chain receives.

In simple word, outcome of the one .then() can be input in the next .then() in the chain.

Promise.resolve(1)
  .then(val => val + 1)      // receives 1, returns 2
  .then(val => val * 10)     // receives 2, returns 20
  .then(val => console.log(val));  // receives 20
// 20

Each .then() transforms the value and passes it to the next. It's a pipeline.

Returning Promises From Chains

When you return a Promise from a .then() handler, the chain waits for that Promise to resolve before calling the next .then():

fetchUser(userId)
  .then(user => {
    return fetchOrders(user.id);  // returns a Promise — chain waits for it
  })
  .then(orders => {
    return fetchDetails(orders[0].id);  // same — returns a Promise
  })
  .then(details => {
    displayDetails(details);
  })
  .catch(err => handleError(err));

This is the mechanic that flattens callback hell. Instead of nesting callbacks inside callbacks, each async step returns a Promise, and the chain handles the sequencing for you.

Conclusion

Promises solved a real, painful problem in JavaScript, and they did it by changing how async results are represented, not just how they're written.

A Promise is a placeholder for a future value. It moves from pending to either fulfilled or rejected , once, permanently. You respond to outcomes with .then() for success, .catch() for failure, and .finally() for cleanup. You chain them to sequence async operations cleanly, without nesting.

The mental model to carry forward: a Promise is a contract. It says "I may not have the answer right now , but I will give you one, and you can tell me what to do with it when I do."

once you're comfortable with Promises, async/await becomes completely natural, because it's just Promises, written to look like the synchronous code you already know.