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:
then and error handlers with catchPromise.allthenThink 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.
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...
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.
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:
Promise.all rejects immediately.Promise.all fulfills with an array of results
in the same order as the input array.
This approach is perfect for concurrency—like loading multiple images or reading multiple files—without waiting for each to finish before starting the next.
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:
manifest.txt, parse it, get the first file name.
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.
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.
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.
Promises let you turn “asynchronous spaghetti” into linear, readable code. This second part of the guide introduced essential promise-handling techniques:
then can handle both success and error if you supply two arguments,
but it’s more common to separate out the error handling with catch.
then, a rejection propagates
to the next one in the chain that does have an error handler.
catch is a convenient error-handling method that works like
a final then dedicated to errors.
Promise.all aggregates multiple promises into one “super promise,”
simplifying concurrency and letting you handle a group of asynchronous tasks at once.
then calls
until that returned promise settles (“flattening” the chain).
new Promise((resolve, reject) => ... )
gives you full control over asynchronous flows.
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.