A Deep Dive into JavaScript Promises: resolve, reject, .then(), .catch(), and .finally()

Published on: by Dr. Talib

While `async/await` provides a clean syntax for handling asynchronous tasks, it is built entirely on top of a more fundamental concept: the **Promise**. Understanding how Promises work under the hood is crucial for any advanced JavaScript developer. A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value.


The States of a Promise

A Promise can be in one of three states:

  • Pending: The initial state; the operation has not yet completed.
  • Fulfilled (or Resolved): The operation completed successfully, and the Promise now has a resulting value.
  • Rejected: The operation failed, and the Promise has a reason for the failure (an error).

Once a Promise is either fulfilled or rejected, it is considered "settled" and its state can never change again. This immutability is a key feature that makes them reliable.

Creating a Promise

While you will most often *consume* Promises returned by Web APIs (like `fetch`), it's important to know how to create one. The `Promise` constructor takes a single argument: an "executor" function. This function itself receives two arguments: `resolve` and `reject`.

Example of Creating a Promise:

const myPromise = new Promise((resolve, reject) => {
  // Simulate an asynchronous operation, like a network request
  setTimeout(() => {
    const success = true; // Change to false to see the rejection

    if (success) {
      // If the operation was successful, call resolve() with the result
      resolve("The data has been fetched successfully!");
    } else {
      // If it failed, call reject() with an error
      reject(new Error("Failed to fetch data."));
    }
  }, 2000);
});

Consuming a Promise with `.then()`, `.catch()`, and `.finally()`

Once you have a Promise object, you attach handlers to it to react to its eventual settlement.

  • .then(onFulfilled, onRejected): The primary method. It takes up to two arguments: a function to run if the Promise is fulfilled, and an optional function to run if it's rejected. It returns a *new* Promise, which is why you can chain them.
  • .catch(onRejected): This is just syntactic sugar for .then(null, onRejected). It's the standard way to handle any errors that occur anywhere in the Promise chain.
  • .finally(onFinally): This takes a function that will run whether the Promise is fulfilled or rejected. It's perfect for cleanup tasks, like hiding a loading spinner.

Example of Consuming Our Promise:

// (Assuming myPromise from the previous example exists)

console.log("Promise is pending...");

myPromise
  .then((successMessage) => {
    // This block runs if the Promise resolves
    console.log("Success:", successMessage);
  })
  .catch((errorMessage) => {
    // This block runs if the Promise rejects
    console.error("Error:", errorMessage.message);
  })
  .finally(() => {
    // This block runs no matter what
    console.log("Promise has settled.");
  });

Try it Yourself: Paste both the creation and consumption code into the HTML Viewer. First, run it with `success = true`. Then, change it to `success = false` and run it again to see the `.catch()` block execute instead.

Chaining Promises

The real power of .then() is that it returns a new Promise. This allows you to chain asynchronous operations in a clean sequence. If you return a value from a .then(), it gets passed as the argument to the next .then(). If you return another Promise, the chain will wait for that new Promise to resolve before continuing.

Promise.resolve(10) // Start with a resolved Promise with value 10
  .then(value => {
    console.log("Step 1:", value); // Logs 10
    return value * 2; // Return a new value
  })
  .then(value => {
    console.log("Step 2:", value); // Logs 20
    // Return a new Promise that resolves after 1 second
    return new Promise(resolve => setTimeout(() => resolve(value + 100), 1000));
  })
  .then(value => {
    console.log("Step 3:", value); // Logs 120 after a 1-second delay
  });

Handling Multiple Promises (Concurrency)

Often, you need to execute multiple asynchronous operations at the same time. The `Promise` object provides powerful static methods for dealing with iterables (like arrays) of Promises.

1. Promise.all()

Takes an array of Promises and returns a single Promise. It resolves only when ALL of the input Promises resolve, returning an array of their results. If any single Promise rejects, the entire `Promise.all` immediately rejects with that error (fail-fast behavior).

const p1 = fetch('/users');
const p2 = fetch('/posts');

Promise.all([p1, p2])
  .then(([usersRes, postsRes]) => {
    // Both finished successfully!
    console.log(usersRes.status, postsRes.status);
  })
  .catch(err => console.error("One of them failed!", err));

2. Promise.allSettled()

Similar to `Promise.all`, but it waits for all Promises to settle (either resolve or reject) and never rejects itself. It returns an array of objects describing the outcome of each Promise. Perfect when you want to fetch multiple independent resources and don't care if one fails.

3. Promise.race()

Returns a Promise that fulfills or rejects as soon as the first Promise in the array settles. It "races" them. This is commonly used for implementing network timeouts.

const fetchData = fetch('/slow-api');
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout!')), 5000));

// If fetchData takes longer than 5 seconds, the timeout wins.
Promise.race([fetchData, timeout])
  .then(data => console.log(data))
  .catch(err => console.error(err));

4. Promise.any()

The opposite of `Promise.all`. It resolves as soon as any one of the Promises fulfills. It only rejects if ALL of the underlying Promises reject.

Common Anti-Patterns to Avoid

Even experienced developers fall into traps with Promises. Watch out for these:

  • The Explicit Construction Anti-Pattern: Do not wrap an existing Promise-based API (like `fetch` or a modern database driver) in a `new Promise()`. Just return the Promise directly.
  • Forgetting to Return: If you perform an asynchronous task inside a `.then()`, you must `return` its Promise. If you forget the `return` keyword, the chain will not wait for it to finish, leading to silent race conditions.
  • Swallowing Errors: Always append a `.catch()` at the end of your Promise chains. Unhandled Promise rejections are a common source of memory leaks and silent application failures.

The Foundation of Modern Asynchronicity

Promises solved the infamous "Callback Hell" problem and provided a sane, reliable way to manage asynchronous operations. While async/await offers a more readable syntax for *consuming* Promises, understanding the underlying `.then()` and `.catch()` mechanism is essential for debugging, writing more complex asynchronous logic, and truly mastering modern JavaScript.