Skip to content
C Codeloom
JavaScript

JavaScript Promises Explained

A practical guide to JavaScript promises — states, then/catch/finally, chaining, Promise.all, race, and allSettled, and how to escape the pyramid of doom.

·9 min read · By Yash Kesharwani
Intermediate 11 min read

What you'll learn

  • The three promise states and how they change
  • then, catch, and finally — and what each one returns
  • How chaining flattens nested async work
  • Promise.all, Promise.race, and Promise.allSettled
  • How errors propagate down a chain
  • Common pitfalls — forgotten returns, swallowed errors

Prerequisites

A promise represents a value that isn’t ready yet. Asynchronous work — a network request, a file read, a timer — doesn’t return its result immediately. A promise is the placeholder the function hands you on the way out, with a way to be notified when the real value arrives.

Promises were the cure for callback hell. The async/await syntax in the next post is the cure for promise sprawl. Both build on the same machinery, and understanding promises makes async/await make sense.

Three states

A promise is in exactly one of three states:

  • Pending — the work is still in progress.
  • Fulfilled — the work finished successfully, with a value.
  • Rejected — the work failed, with a reason (usually an Error).

Once a promise settles (fulfilled or rejected), it stays that way forever. You can’t un-fulfill it or change its value.

Creating a promise

You’ll rarely write new Promise by hand — most APIs already return promises. But the constructor shows what’s happening:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = Math.random() > 0.5;
    if (success) {
      resolve("it worked");
    } else {
      reject(new Error("it broke"));
    }
  }, 200);
});

The function you pass to new Promise is the executor. It receives two callbacks: call resolve(value) to fulfill the promise, or reject(reason) to reject it.

In real code you’ll see this pattern only when wrapping a callback-based API. Everything modern — fetch, fs.promises, database libraries — returns promises directly.

Consuming a promise: then, catch, finally

Attach handlers with .then, .catch, and .finally:

promise
  .then((value) => {
    console.log("fulfilled:", value);
  })
  .catch((error) => {
    console.log("rejected:", error.message);
  })
  .finally(() => {
    console.log("done");
  });
  • .then(onFulfilled) runs when the promise fulfills, with its value.
  • .catch(onRejected) runs when the promise rejects, with its reason.
  • .finally(onSettled) runs in either case, with no argument.

Each of these returns a new promise, which is what makes chaining work.

Chaining

The big idea: whatever you return from a .then callback becomes the value of the next promise in the chain.

Promise.resolve(2)
  .then((n) => n * 3)
  .then((n) => n + 1)
  .then((n) => console.log(n));    // 7

If you return a promise from inside .then, the chain waits for it before continuing:

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Promise.resolve("start")
  .then((s) => {
    console.log(s);
    return delay(500);          // returning a promise
  })
  .then(() => {
    console.log("after delay");
    return delay(500);
  })
  .then(() => console.log("done"));

This is what flattens nested async work. Each step is one .then, and each runs after the previous one finishes. No more nested callbacks.

Promise.resolve(value) and Promise.reject(reason) are shortcuts for already-settled promises. They’re handy for starting a chain or for testing.

A realistic example: fetch

The browser fetch API returns a promise. So does response.json():

fetch("https://api.example.com/users/1")
  .then((response) => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  })
  .then((user) => {
    console.log(user.name);
  })
  .catch((error) => {
    console.error("Failed:", error.message);
  });

Three things to notice:

  1. The first .then returns response.json() — a promise — so the chain waits for the body to parse before the next step.
  2. Throwing inside .then rejects the resulting promise, jumping straight to .catch.
  3. One .catch at the end handles errors from any step. That’s the appeal of chaining.

Error propagation

Errors flow down a chain until something handles them. Any of these three things rejects the next promise:

  • An explicit throw
  • Returning Promise.reject(...)
  • A promise you return rejecting on its own

A single .catch at the bottom is usually enough:

loadUser(1)
  .then(loadFriends)
  .then(displayFriends)
  .catch((err) => showError(err));

If loadUser, loadFriends, or displayFriends rejects, the catch handler runs. The intermediate steps are skipped.

A common mistake is putting .catch in the middle of a chain expecting it to “rethrow”:

loadUser(1)
  .catch((err) => console.log(err))   // catches AND swallows the error
  .then(loadFriends);                 // runs anyway, with undefined

.catch returns a fulfilled promise (unless it throws itself). The chain continues with whatever the catch handler returned. If you want to log and rethrow:

.catch((err) => {
  console.log(err);
  throw err;
})

Try it yourself. Write a function pause(ms, value) that returns a promise resolving to value after ms milliseconds. Chain three calls — pause(200, "a"), then pause(200, "b"), then pause(200, "c") — and log each value as it arrives. Total elapsed time should be roughly 600 ms.

Running promises in parallel: Promise.all

When you have several independent promises and want to wait for all of them, use Promise.all. It takes an iterable of promises and returns a single promise that fulfills with an array of all the values — in the original order — once they’ve all fulfilled.

const userPromise = fetch("/api/user");
const postsPromise = fetch("/api/posts");
const commentsPromise = fetch("/api/comments");

Promise.all([userPromise, postsPromise, commentsPromise])
  .then(([userRes, postsRes, commentsRes]) => {
    console.log("all three loaded");
  })
  .catch((err) => {
    console.error("at least one failed:", err);
  });

The crucial property: the requests are issued immediately, in parallel. Total wall time is the time of the slowest request, not the sum. Compare with sequential chaining, which forces each request to wait for the previous one.

Failure semantics: if any promise rejects, the resulting promise rejects immediately with that reason — the other promises continue running but their results are ignored. This is sometimes called fail-fast behaviour.

When you can tolerate failures: Promise.allSettled

If you want every promise to finish regardless of failures, use Promise.allSettled. It always fulfills, with an array of result objects:

Promise.allSettled([
  fetch("/api/user"),
  fetch("/api/maybe-broken"),
  fetch("/api/posts"),
]).then((results) => {
  results.forEach((r, i) => {
    if (r.status === "fulfilled") {
      console.log(i, "ok:", r.value);
    } else {
      console.log(i, "failed:", r.reason);
    }
  });
});

Each result is { status: "fulfilled", value } or { status: "rejected", reason }. Use allSettled for “log everything, give me whatever worked” workflows.

When you only need the first: Promise.race and Promise.any

Promise.race(iterable) settles as soon as any promise settles — whether by fulfilling or rejecting. Classic use case is timeout:

function timeout(ms) {
  return new Promise((_, reject) =>
    setTimeout(() => reject(new Error("timeout")), ms)
  );
}

Promise.race([
  fetch("/api/slow-thing"),
  timeout(3000),
])
  .then((res) => console.log("got it"))
  .catch((err) => console.error(err.message));

Promise.any(iterable) fulfills with the first promise that fulfills, ignoring earlier rejections. It rejects only if every promise rejects. Useful for “try these three mirrors, give me whichever responds first”:

Promise.any([
  fetch("https://mirror-a.example/data"),
  fetch("https://mirror-b.example/data"),
  fetch("https://mirror-c.example/data"),
]).then((res) => console.log("got it from one of them"));

A quick reference table

CombinatorSettles whenFulfilled valueRejected reason
Promise.allall fulfill, or any rejectsarray of all valuesfirst rejection reason
Promise.allSettledall settlearray of result objectsnever rejects
Promise.raceany settlesfirst fulfilled valuefirst rejection reason
Promise.anyany fulfills, or all rejectfirst fulfilled valueAggregateError of all reasons

Pick by what behaviour you want when things go wrong — that’s the easiest way to choose.

The pyramid of doom — and how to flatten it

Before promises, asynchronous code stacked callbacks inside callbacks:

loadUser(1, (err, user) => {
  if (err) return handle(err);
  loadFriends(user, (err, friends) => {
    if (err) return handle(err);
    loadAvatars(friends, (err, avatars) => {
      if (err) return handle(err);
      display(user, friends, avatars);
    });
  });
});

The visual rightward drift, with manual error checking at every level, is the pyramid of doom. Promises let you write the same thing flat:

loadUser(1)
  .then((user) => loadFriends(user).then((friends) => ({ user, friends })))
  .then(({ user, friends }) =>
    loadAvatars(friends).then((avatars) => ({ user, friends, avatars }))
  )
  .then(({ user, friends, avatars }) => display(user, friends, avatars))
  .catch(handle);

Better — but still some bookkeeping for passing earlier values forward. The async/await syntax in the next post takes this all the way to flat, sequential-looking code.

Try it yourself. Use Promise.all to “fetch” three things in parallel. Mock the fetches with pause(500, "user"), pause(800, "posts"), pause(300, "comments"). Log the array of results and the total elapsed time. It should be about 800 ms, not 1600 ms.

Common pitfalls

A short list of mistakes that catch everyone at least once.

Forgetting to return inside .then

loadUser(1)
  .then((user) => {
    loadFriends(user);          // return is missing
  })
  .then((friends) => {
    console.log(friends);       // undefined
  });

Without the return, the chain doesn’t wait for loadFriends. Always return promises you create inside .then.

Swallowing errors

A .catch that just logs makes the chain continue as if nothing went wrong. Rethrow if downstream steps should be skipped.

Mixing callbacks and promises

If a library returns a promise, don’t also pass it a callback. Pick one style and stick to it.

Creating a new Promise wrapping an existing one

function load() {
  return new Promise((resolve, reject) => {
    fetch("/x").then(resolve, reject);    // unnecessary
  });
}

fetch already returns a promise. Just return it.

Recap

You now know:

  • A promise is pending, fulfilled, or rejected — and once settled, stays that way
  • .then runs on fulfillment, .catch on rejection, .finally on either
  • Each handler returns a new promise — that’s why chaining works
  • Returning a promise from .then makes the chain wait for it
  • Errors propagate down the chain until something handles them
  • Promise.all runs in parallel and fails fast; allSettled waits for everything
  • Promise.race and Promise.any settle on the first promise to finish

Next steps

The next post is about async/await — syntactic sugar over promises that lets asynchronous code look completely synchronous. You’ll keep all the promise concepts; the surface gets dramatically simpler.

→ Next: async/await in JavaScript: A Practical Guide

Questions or feedback? Email codeloomdevv@gmail.com.