Handling Promises: A Mostly Complete Guide to JavaScript Promises II

In Part I, we explored the concept of JavaScript Promises: why they’re useful, how they help you avoid callback hell, and the three possible states (pending, fulfilled, rejected). Now, we’ll dive deeper into how to handle promises using then, catch, and Promise.all, and we’ll discover how you can create your own promises from scratch.

By the end, you’ll be able to:


Handling Success with then

Think about a file-reading scenario in Node.js. Here’s a classic callback approach:

readFile("manifest.txt", "utf8", (err, manifest) => {
  if (err) {
    console.error("Badness happened", err);
  } else {
    const fileList = manifest.split("\n");
    console.log("Reading", fileList.length, "files");
  }
});

Using a Promise-based API (like a hypothetical readFilePromise), you might write:

readFilePromise("manifest.txt").then(manifest => {
  const fileList = manifest.split("\n");
  console.log("Reading", fileList.length, "files");
});

This approach is sleeker than the callback version—it removes nested logic and manages success handling in a single place.


Chaining then Calls

One of the best parts about promises is the ability to chain multiple then calls. Each then returns a new promise, letting you transform data step by step. An explicit example might look like this:

// 1. Read file => returns a promise with file content
// 2. Then split content => returns a promise with an array
// 3. Then compute the length => returns a promise with a number
// 4. Then log the result
readFilePromise("manifest.txt")
  .then(manifest => manifest.split("\n"))
  .then(fileList => fileList.length)
  .then(numberOfFiles => console.log("Reading", numberOfFiles, "files"));

Each step transforms the result before passing it along. This approach is akin to an assembly line: each station works on the product (the data) and hands it to the next station.

If all goes well, the final value is what you see in the last then. If any step fails, we need a way to handle that, which leads us to...


Handling Failure with then

The then method can accept two parameters: onFulfilled and onRejected. For instance:

readFilePromise("manifest.txt").then(
  manifest => {
    console.log("Successfully read file!");
  },
  reason => {
    console.error("Badness happened", reason);
  }
);

If reading the file fails (maybe it doesn’t exist), onRejected runs with the error. If it succeeds, onFulfilled executes. However, combining both success and error handlers in one then can lead to more indentation. That’s where catch shines.


Handling Failure with catch

The .catch method attaches an error handler to the end of a promise chain, making your code more streamlined:

readFilePromise("manifest.txt")
  .then(manifest => manifest.split("\n"))
  .then(fileList => fileList.length)
  .then(numberOfFiles => console.log("Reading", numberOfFiles, "files"))
  .catch(err => console.error("Badness happened", err));

If any of the steps in the chain throws an error or rejects, the code jumps to catch. If catch doesn’t raise an exception, the promise chain returns a fulfilled state with whatever value catch might return (if any).


Promise.all: Managing Multiple Promises

Sometimes, you’ll want to launch multiple asynchronous operations in parallel. For instance, you might read several files at once. Promise.all helps unify these separate promises into one super promise:

const promisesArray = [
  readFilePromise("file-boop.txt"),
  readFilePromise("file-doop.txt"),
  readFilePromise("file-goop.txt")
];

Promise.all(promisesArray)
  .then(contents => {
    // contents is an array of [boopFileContent, doopFileContent, goopFileContent]
    console.log("All files read successfully!", contents);
  })
  .catch(error => {
    // If ANY file fails, the entire chain goes here
    console.error("Failed reading one of the files", error);
  });

Key points:

This approach is perfect for concurrency—like loading multiple images or reading multiple files—without waiting for each to finish before starting the next.


Flattening Promises: Returning a Promise in a Handler

One magical property of promises is that returning a promise from a handler “pauses” the chain until that returned promise resolves or rejects:

readFilePromise("manifest.txt")
  .then(manifestContent => manifestContent.split("\n"))
  .then(fileList => fileList[0])
  .then(fileName => {
    // Return a new promise that reads the file
    return readFilePromise(fileName);
  })
  .then(otherFileContent => {
    console.log("Content of file in the first line of manifest:", otherFileContent);
  })
  .catch(err => console.error("Something went wrong:", err));

Steps:

  1. Read manifest.txt, parse it, get the first file name.
  2. Return a new promise to read that file.
  3. When that new promise finishes, log the content.

Because a promise is returned, the next then in the chain waits until readFilePromise(fileName) settles. This flattening effect is what allows sequential asynchronous operations to appear in a neat chain.


Putting It All Together

Let’s revisit the classic example: reading a manifest.txt file with a list of other files, reading each file’s contents, counting characters, and printing the result. With promises, it might look like:

const characterCounts = {};

readFilePromise("manifest.txt")
  .then(manifest => manifest.split("\n"))
  .then(fileList => fileList.map(fileName => readFilePromise(fileName)))
  .then(arrayOfFilePromises => Promise.all(arrayOfFilePromises))
  .then(contentsArray => {
    // contentsArray is the content of each file
    contentsArray.forEach(content => {
      countCharacters(characterCounts, content);
    });
  })
  .then(() => {
    console.log(characterCounts);
  })
  .catch(reason => console.error(reason));

Compared to deeply nested callbacks, this code is easier to follow. You can read it top-to-bottom, with each step being a then or catch.


Creating Your Own Promises

Sometimes you want to wrap your own asynchronous logic in a promise. For instance, you might create a simple pause function that returns a promise that resolves after a certain timeout:

function pause(seconds) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(), seconds * 1000);
  });
}

function log(message) {
  console.log(message);
  return Promise.resolve(); // returns a fulfilled promise
}

// Example usage:
log("Q")
  .then(() => log("W"))
  .then(() => pause(2))
  .then(() => log("E"))
  .then(() => log("R"))
  .then(() => pause(1))
  .then(() => log("T"));

The log function simply logs a message and returns a promise that’s already fulfilled. The pause function does some asynchronous operation (a timeout), and calls resolve() when done. In real-world code, you might do something more meaningful—like reading from a database, performing calculations, or retrieving data from an external API.

Another example is wrapping readFile from Node’s fs module:

const { readFile } = require("fs");

function readFilePromise(path) {
  return new Promise((resolve, reject) => {
    readFile(path, "utf8", (err, content) => {
      if (err) {
        reject(err);
      } else {
        resolve(content);
      }
    });
  });
}

Here, you manually call resolve if the file read succeeds, or reject if it fails. The function returns the new promise so your code can use .then and .catch on it.


What You’ve Learned

Promises let you turn “asynchronous spaghetti” into linear, readable code. This second part of the guide introduced essential promise-handling techniques:

With these tools, you can orchestrate complex async operations in an elegant, top-to-bottom manner. Modern JavaScript also offers async/await, which builds on these concepts to make your code read even more like synchronous code—but promises are the foundation upon which async/await is built. Master them, and you’re well on your way to writing maintainable, high-quality asynchronous JavaScript.