Promises

Represent a future result as an object, chain steps with then, and handle every failure in one place.

  • Beginner
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Chain promises
  • Handle rejected promises
  • Wrap a callback-based function in a promise
  • Predict when promise handlers run

Before you start

A promise is an object that stands for a result you do not have yet. Instead of passing a callback into an asynchronous function, you get a promise back and attach what should happen next. Promises fix the nesting, error handling and trust problems from the previous lesson, and they are the foundation of async/await and fetch().

The three states

A promise is always in one of three states:

StateMeaning
pendingThe work has not finished yet
fulfilledThe work succeeded, and the promise holds a value
rejectedThe work failed, and the promise holds a reason, usually an Error

A promise that is fulfilled or rejected is settled. Settling happens once. After that the state and value never change, which is why a promise-based function cannot "call you back twice".

Consuming a promise

Most of the time you use promises that other code creates. fetch() returns one, and so does readFile from node:fs/promises. You react to the result with three methods:

JavaScript
fetch("data/courses.json")
  .then((response) => {
    console.log("Status:", response.status);
  })
  .catch((error) => {
    console.error("Request failed:", error.message);
  })
  .finally(() => {
    console.log("Finished, whatever happened");
  });
  • .then(onFulfilled) runs when the promise fulfils, with the value.
  • .catch(onRejected) runs when the promise rejects, with the reason.
  • .finally(onFinally) runs either way, with no argument. It is the place to hide a loading spinner.

fetch() gets a whole lesson of its own in lesson 33. For now, a timer makes a better playground.

Creating a promise

You create a promise with the Promise constructor. It takes an executor function, which receives two functions: resolve to fulfil the promise and reject to reject it.

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

delay(1000).then(() => console.log("One second later"));

delay wraps a callback-based API, setTimeout, in a promise. This is the main reason to call new Promise yourself: to turn an old callback API into a promise. Here is the course loader from lesson 30, rewritten:

JavaScript
const courses = [
  { id: 1, title: "JavaScript Basics", authorId: 7 },
  { id: 2, title: "CSS Grid in Practice", authorId: 9 },
];

function loadCourse(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const course = courses.find((c) => c.id === id);
      if (course) {
        resolve(course);
      } else {
        reject(new Error(`No course with id ${id}`));
      }
    }, 500);
  });
}

loadCourse(1)
  .then((course) => console.log(course.title)) // → JavaScript Basics
  .catch((error) => console.error(error.message));

loadCourse(99)
  .then((course) => console.log(course.title))
  .catch((error) => console.error(error.message)); // → No course with id 99

Always reject with an Error object, not a string. An Error carries a stack trace that tells you where the failure happened.

If the executor throws, the promise rejects with the thrown error, so you do not need a try...catch inside it.

Node.js already ships promise versions of most of its APIs: node:fs/promises, node:timers/promises and others. Check for one before wrapping a callback API yourself.

Chaining

Every call to .then() returns a new promise. That new promise settles with whatever your handler returns:

  • Return a plain value, and the next .then() receives it.
  • Return a promise, and the chain waits for it, then passes on its result.
  • Throw an error, and the chain skips to the next .catch().

That is what flattens the pyramid from lesson 30:

JavaScript
loadCourse(1)
  .then((course) => loadAuthor(course.authorId))
  .then((author) => loadCoursesBy(author.id))
  .then((otherCourses) => render(otherCourses))
  .catch((error) => showError(error));

Each step waits for the previous one, the code reads top to bottom, and one .catch() handles a failure at any step.

Here is a chain you can run in the console that transforms values step by step:

JavaScript
Promise.resolve(2)
  .then((n) => n * 10)
  .then((n) => delay(500).then(() => n + 1))
  .then((n) => console.log(n)); // → 21 (after half a second)

Promise.resolve(value) creates a promise that is already fulfilled with value. Promise.reject(error) creates one that is already rejected.

The most common mistake: forgetting to return

JavaScript
loadCourse(1)
  .then((course) => {
    loadAuthor(course.authorId); // no return!
  })
  .then((author) => {
    console.log(author); // → undefined
  });

Without return, the handler returns undefined, so the next step runs immediately with undefined and does not wait for the author. Worse, if loadAuthor rejects, nothing catches it. With arrow functions, an expression body without braces returns automatically, which is one reason chains are usually written that way.

Do not nest

JavaScript
// Avoid: nesting brings the pyramid back
loadCourse(1).then((course) => {
  loadAuthor(course.authorId).then((author) => {
    render(course, author);
  });
});

If a later step needs an earlier value, async/await (next lesson) is the clean answer. In a pure promise chain, you can return both values together, for example as an object.

Error handling in detail

A rejection travels down the chain, skipping every .then() until it reaches a .catch():

JavaScript
Promise.resolve(1)
  .then(() => {
    throw new Error("Step 2 failed");
  })
  .then(() => console.log("Skipped"))
  .catch((error) => {
    console.error(error.message); // → Step 2 failed
    return "recovered";
  })
  .then((value) => console.log(value)); // → recovered

.catch() also returns a new promise. If the handler returns normally, the chain carries on as fulfilled. If you cannot recover, throw again (or throw a new, more specific error) so that callers further down still see a failure.

.then() also accepts a second argument for rejections, .then(onFulfilled, onRejected). It is rarely used, because onRejected there does not catch errors thrown by onFulfilled in the same call. A separate .catch() at the end catches both.

finally

.finally() runs whatever the outcome and passes the original result through unchanged:

JavaScript
showSpinner();

loadCourse(1)
  .then((course) => render(course))
  .catch((error) => showError(error))
  .finally(() => hideSpinner());

Unhandled rejections

A rejected promise with no .catch() anywhere is an unhandled rejection. Browsers log it in the console as "Uncaught (in promise)". In Node.js, the default since version 15 is to crash the process. Every chain should end in a .catch(), or be returned to a caller that handles it.

When do handlers run?

Promise handlers never run immediately, even if the promise is already settled. They are queued as microtasks, which run as soon as the current synchronous code has finished and before any timer callback.

JavaScript
console.log("A");

setTimeout(() => console.log("D: timeout"), 0);

Promise.resolve().then(() => console.log("C: promise"));

console.log("B");

// → A
// → B
// → C: promise
// → D: timeout

The synchronous lines run first (A, B). Then the microtask queue empties (C). Only then does the timer's callback get its turn (D), even though it was scheduled earlier.

The executor, by contrast, runs synchronously, the moment you call new Promise:

JavaScript
console.log("1");

new Promise((resolve) => {
  console.log("2: inside executor");
  resolve();
}).then(() => console.log("4: then"));

console.log("3");

// → 1
// → 2: inside executor
// → 3
// → 4: then

This consistency is another promise guarantee: a handler is always called asynchronously, so code after .then() always runs before the handler. Lesson 42 covers the event loop, tasks and microtasks in depth.

Avoid wrapping a promise in a new promise

If a function already returns a promise, do not wrap it:

JavaScript
// Avoid
function getTitle(id) {
  return new Promise((resolve, reject) => {
    loadCourse(id)
      .then((course) => resolve(course.title))
      .catch(reject);
  });
}

// Better
function getTitle(id) {
  return loadCourse(id).then((course) => course.title);
}

The second version is shorter, and it cannot accidentally forget to forward an error.

Summary

  • A promise is pending, then settles once as fulfilled (with a value) or rejected (with a reason).
  • Use .then() for success, .catch() for failure and .finally() for clean-up.
  • Create promises with new Promise((resolve, reject) => ...), mainly to wrap callback APIs. Reject with Error objects.
  • Each .then() returns a new promise. Return values and promises from handlers to build a flat chain.
  • A thrown error or rejection skips to the next .catch(); returning from .catch() recovers.
  • Always handle rejections. Unhandled rejections are logged in browsers and crash Node.js by default.
  • Handlers run as microtasks: after the current synchronous code, before timers. The executor runs synchronously.

Further reading