Skip to main content

Command Palette

Search for a command to run...

JavaScript Promises Explained for Beginners

Updated
12 min read
C
Software developer passionate about building scalable web applications with React and backend technologies. I enjoy solving problems, building projects, and sharing my learning with the community.

Before promises existed, JavaScript developers handled asynchronous operations with callbacks. It worked. But as applications grew more complex, callbacks had a habit of becoming a nightmare — nested inside each other, with error handling scattered everywhere, and logic that was nearly impossible to follow.

Promises were introduced to solve that. Not to make async code synchronous, but to make it readable, predictable, and manageable. This post explains what promises are, how they work from the inside out, and how to use them cleanly.


The problem promises solve

To appreciate promises, you need to feel the pain they replace. Callbacks first.

Suppose you're building a feature: load a user from the database, then load their orders, then calculate the total value of those orders. Each step depends on the previous one, and each is asynchronous.

With callbacks, it looks like this:

getUser(userId, function(err, user) {
  if (err) {
    console.error("Failed to get user:", err);
    return;
  }

  getOrders(user.id, function(err, orders) {
    if (err) {
      console.error("Failed to get orders:", err);
      return;
    }

    calculateTotal(orders, function(err, total) {
      if (err) {
        console.error("Failed to calculate:", err);
        return;
      }

      console.log(`Total for ${user.name}: $${total}`);
    });
  });
});

This is callback hell — sometimes called the pyramid of doom. Every step nests deeper. Error handling repeats three times. The actual logic (console.log) is buried at the bottom of four levels of indentation.

And this is only three steps. Real applications have more.

Now look at the same logic with promises:

getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => calculateTotal(orders))
  .then(total => console.log(`Total: $${total}`))
  .catch(err => console.error("Something failed:", err));

Same operations. One level of indentation. One error handler for everything. Reads top to bottom like a sentence.

That's the promise.


What a promise actually is

A promise is an object that represents the eventual result of an asynchronous operation. You don't have the value yet — but you have a placeholder for it, and you can attach code to run when the value arrives.

Think of it like ordering food at a restaurant. The waiter doesn't hand you the food immediately. They give you a receipt — a promise that food is coming. While you wait, you can plan what you'll do when it arrives (eat it) or what you'll do if it doesn't (complain and leave). The receipt doesn't give you food early. It gives you a way to handle both outcomes.

In JavaScript:

const promise = new Promise((resolve, reject) => {
  // async work happens here
  // call resolve(value) when successful
  // call reject(error) when something fails
});

The function you pass to new Promise is called the executor. It runs immediately and synchronously. Inside it, you perform your async work (a network request, a file read, a database query), and when it finishes, you call either resolve with the result, or reject with an error.


The three states of a promise

Every promise lives in exactly one of three states at any given moment.

Pending — the initial state. The async work has started but hasn't finished. The promise has no result yet.

Fulfilled — the async work completed successfully. resolve() was called. The promise has a value.

Rejected — the async work failed. reject() was called. The promise has a reason (an error).

Two things to know about states:

  1. A promise can only change state once. Fulfilled promises can't become rejected. Rejected promises can't become fulfilled.

  2. Once settled (either fulfilled or rejected), the promise is immutable — it will always return the same value or the same error.

This predictability is one of the core reasons promises are easier to reason about than callbacks.


Creating a promise

Let's build a simple promise manually to see exactly how the states work:

// A promise that fulfils after 1 second
const myPromise = new Promise((resolve, reject) => {
  console.log("Executor running..."); // runs immediately

  setTimeout(() => {
    const success = true; // change to false to test rejection

    if (success) {
      resolve("Operation completed successfully!");
    } else {
      reject(new Error("Something went wrong."));
    }
  }, 1000);
});

console.log("Promise created:", myPromise); // Promise { <pending> }
// The promise is pending here — the setTimeout hasn't fired yet

At the moment myPromise is logged, it's pending. One second later, the setTimeout fires, calls resolve(), and the promise becomes fulfilled.


Handling the result: .then(), .catch(), .finally()

Creating a promise doesn't do much on its own. You need to attach handlers that respond to the outcome.

.then() — handling success

.then() takes a callback that runs when the promise fulfils. The fulfilled value is passed as the argument.

myPromise.then(result => {
  console.log(result); // "Operation completed successfully!"
});

.catch() — handling failure

.catch() takes a callback that runs when the promise rejects. The rejection reason (usually an Error) is passed as the argument.

const failingPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject(new Error("Database connection failed"));
  }, 500);
});

failingPromise.catch(err => {
  console.error(err.message); // "Database connection failed"
});

Using both together

fetch("https://api.example.com/users/1")
  .then(response => response.json())
  .then(user => {
    console.log("Got user:", user.name);
  })
  .catch(err => {
    console.error("Request failed:", err.message);
  });

.catch() at the end catches rejection from any .then() in the chain — more on that in a moment.

.finally() — always runs

.finally() runs regardless of whether the promise fulfilled or rejected. It's for cleanup code that should always execute — hiding a loading spinner, closing a connection, resetting state.

let isLoading = true;

fetch("https://api.example.com/data")
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(err => console.error(err))
  .finally(() => {
    isLoading = false; // always runs, success or failure
    console.log("Request finished.");
  });

.finally() doesn't receive the fulfilled value or the rejection reason — it just runs. If you need those values, use .then() or .catch().


A realistic promise example

Rather than just using setTimeout, let's write something closer to real code. Here's a function that simulates fetching a user from a database:

function getUser(id) {
  return new Promise((resolve, reject) => {
    // Simulating a DB query with setTimeout
    setTimeout(() => {
      const users = {
        1: { id: 1, name: "Aarav",  email: "aarav@example.com"  },
        2: { id: 2, name: "Priya",  email: "priya@example.com"  },
        3: { id: 3, name: "Rahul",  email: "rahul@example.com"  }
      };

      const user = users[id];

      if (user) {
        resolve(user);
      } else {
        reject(new Error(`User with ID ${id} not found`));
      }
    }, 300);
  });
}

// Use it
getUser(2)
  .then(user => console.log(`Found: \({user.name} — \){user.email}`))
  .catch(err => console.error(err.message));

// → "Found: Priya — priya@example.com"

getUser(99)
  .then(user => console.log(user))
  .catch(err => console.error(err.message));

// → "User with ID 99 not found"

The function always returns a promise. The caller decides what to do with it using .then() and .catch(). The function itself doesn't know or care what the caller will do — it just resolves or rejects.


Promise chaining

This is where promises go from useful to genuinely powerful. .then() returns a new promise — which means you can chain .then() calls one after another, and each step receives the result of the previous one.

getUser(1)
  .then(user => {
    console.log("Step 1:", user.name);
    return user.id; // pass the id to the next step
  })
  .then(userId => {
    console.log("Step 2: got user id:", userId);
    return getOrders(userId); // return a new promise
  })
  .then(orders => {
    console.log("Step 3: got orders:", orders.length);
    return orders.reduce((total, order) => total + order.amount, 0);
  })
  .then(total => {
    console.log("Step 4: total is $" + total);
  })
  .catch(err => {
    console.error("Something failed:", err.message);
    // Catches errors from ANY step above
  });

Each .then() either returns a plain value (passed directly to the next .then()) or a new promise (waited on before passing its result to the next .then()). This is what makes chaining work with async functions at each step.

The critical rule about returning

Inside a .then() callback, you must return the value or promise you want passed along. If you forget to return, the next .then() receives undefined.

// ❌ Forgot to return — next step gets undefined
getUser(1)
  .then(user => {
    getOrders(user.id); // result not returned!
  })
  .then(orders => {
    console.log(orders); // undefined
  });

// ✅ Return the promise — chaining works correctly
getUser(1)
  .then(user => {
    return getOrders(user.id); // returned!
  })
  .then(orders => {
    console.log(orders.length); // correct
  });

Error propagation through a chain

One of the best things about promise chains is how errors propagate. If any .then() in the chain throws an error or returns a rejected promise, execution skips all remaining .then() handlers and jumps straight to the nearest .catch().

getUser(1)
  .then(user => {
    return getOrders(user.id); // let's say this rejects
  })
  .then(orders => {
    // Skipped entirely if getOrders rejected
    return calculateTotal(orders);
  })
  .then(total => {
    // Also skipped
    console.log("Total:", total);
  })
  .catch(err => {
    // Catches the rejection from getOrders (or any step)
    console.error("Pipeline failed:", err.message);
  });

Compare this to the callback version where you had to handle errors at every single step. One .catch() at the end covers the entire chain.


Promise.resolve() and Promise.reject()

Sometimes you need to create an already-settled promise — a promise that's immediately fulfilled or rejected without any async work.

// Already fulfilled
const resolved = Promise.resolve(42);
resolved.then(val => console.log(val)); // 42

// Already rejected
const rejected = Promise.reject(new Error("immediate failure"));
rejected.catch(err => console.error(err.message)); // "immediate failure"

These are useful when you need to return a promise from a function but already have the value synchronously:

function getConfig(key) {
  const cache = { theme: "dark", lang: "en" };

  if (cache[key] !== undefined) {
    return Promise.resolve(cache[key]); // already have it
  }

  return fetchConfigFromServer(key); // need to fetch it
}

// Either way, the caller uses .then()
getConfig("theme").then(val => console.log(val)); // "dark"

Working with multiple promises

What if you need to run several async operations and wait for all of them?

Promise.all() — wait for everything

const userPromise   = getUser(1);
const ordersPromise = getOrders(1);
const configPromise = getConfig("theme");

Promise.all([userPromise, ordersPromise, configPromise])
  .then(([user, orders, config]) => {
    // All three finished — results destructured in order
    console.log(user.name, orders.length, config);
  })
  .catch(err => {
    // If ANY one rejects, this fires immediately
    console.error("One of them failed:", err.message);
  });

Promise.all() runs all promises in parallel (simultaneously, not sequentially). It fulfils when every promise fulfils, and rejects as soon as any single promise rejects.

Use this when you need all results before continuing, and the operations are independent of each other.

Promise.allSettled() — wait for everything, regardless of outcome

const results = await Promise.allSettled([
  getUser(1),
  getUser(99),    // this one rejects
  getConfig("theme")
]);

results.forEach(result => {
  if (result.status === "fulfilled") {
    console.log("Success:", result.value);
  } else {
    console.log("Failed:", result.reason.message);
  }
});

Unlike Promise.all(), Promise.allSettled() never rejects. It waits for every promise to settle and gives you an array describing each outcome. Use this when you want to attempt several operations and handle each result individually, even if some fail.

Promise.race() — first one wins

const timeout = new Promise((_, reject) =>
  setTimeout(() => reject(new Error("Request timed out")), 5000)
);

Promise.race([fetchData(), timeout])
  .then(data => console.log("Got data:", data))
  .catch(err => console.error(err.message));

Promise.race() settles with the result of whichever promise settles first — fulfilled or rejected. It's commonly used to implement timeouts.


Promises vs callbacks: the real comparison

Callbacks aren't wrong — they're still used in many places and work fine for simple one-off async calls. The difference becomes obvious as complexity grows. Promises scale better because chaining stays flat and error handling is centralised.


What comes after promises

Once you're comfortable with promises, the next step is async/await — syntax introduced in ES2017 that lets you write promise-based code that looks synchronous:

// Promise chain
getUser(1)
  .then(user => getOrders(user.id))
  .then(orders => calculateTotal(orders))
  .then(total => console.log(total))
  .catch(err => console.error(err));

// Same thing with async/await
async function loadUserTotal(userId) {
  try {
    const user   = await getUser(userId);
    const orders = await getOrders(user.id);
    const total  = await calculateTotal(orders);
    console.log(total);
  } catch (err) {
    console.error(err);
  }
}

async/await doesn't replace promises — it's built on top of them. Every async function returns a promise. Every await expression unwraps one. Understanding promises deeply makes async/await immediately intuitive, rather than something you cargo-cult without knowing why it works.


Summary

Promises solve the readability and maintainability problems that callbacks created as async code grew complex. They represent a value that isn't ready yet — but will be — and give you a clean way to say "when it's ready, do this."

The key ideas to hold onto:

  • A promise is always in one of three states: pending, fulfilled, or rejected

  • Once settled, it stays settled — same value or same error, every time

  • .then() handles success, .catch() handles failure, .finally() always runs

  • Chains stay flat because .then() returns a new promise

  • One .catch() at the end of a chain handles errors from any step

  • Promise.all() for parallel operations, Promise.allSettled() when you need all results regardless of failure

Promises are one of those things that feel abstract until the moment they click. When they do, your async code will never look the same.


More from this blog

Why Node.js is Perfect for Building Fast Web Applications

Every technology makes a bet. Node.js's bet was this: most web applications aren't slow because they do too much computation. They're slow because they spend most of their time waiting — waiting for a database to respond, waiting for a file to load, waiting for an external API to return. If you build a runtime optimised around that specific reality, you get something genuinely fast for the work most web apps actually do.

May 9, 202611 min read1
C

Chetan Chauhan | Tech Blog | chetan71

41 posts