...

/

Using Promises

Using Promises

Learn how Promises simplify asynchronous tasks in Node.js and overcome callback limitations.

As we’ve seen in previous lessons, Node.js handles asynchronous tasks with callbacks—functions that execute once a task completes. However, as the complexity of asynchronous tasks grows, so does the problem of managing these callbacks. Nesting multiple callbacks quickly leads to callback hell, a situation where deeply nested and hard-to-read code becomes challenging to debug and maintain.

For example, imagine a program that reads a file, processes its contents, then writes to another file and logs the result. With callbacks, this can get messy:

// Example of callback hell
fs.readFile('input.txt', 'utf8', (err, data) => {
if (err) return console.error(err);
processData(data, (err, result) => {
if (err) return console.error(err);
fs.writeFile('output.txt', result, (err) => {
if (err) return console.error(err);
console.log('File written successfully');
});
});
});
Example of callback hell in handling asynchronous tasks

This structure makes the flow difficult to follow, increasing the likelihood of errors. Promises offer a way to manage asynchronous code more effectively, making it easier to read and handle errors.

What is a Promise?

A Promise represents an asynchronous operation that will complete in the future, either successfully (fulfilled) or with an error (rejected). By allowing us to manage asynchronous code as promises of future results, Promises create a smoother code flow.

Promises provide a way to escape the complexity of nested callbacks by enabling sequential handling of asynchronous tasks. With Promises, error handling becomes centralized and more manageable, making asynchronous code easier to read, debug, and maintain.

A Promise has three possible states:

  1. Pending: The initial state, before the asynchronous task completes.

  2. Fulfilled: The task has completed successfully.

  3. Rejected: An error occurred in the task.

Creating a Promise

Creating a new Promise involves using new Promise((resolve, reject) => {...}), where resolve and reject are functions:

  • resolve is called when the task completes successfully.

  • reject is called when the task encounters an error.

Press + to interact
Anatomy of a Promise
Anatomy of a Promise

To see how Promises work in practice, let’s create a simple Promise that simulates a task and handles success or failure. This example will demonstrate how to set up a Promise and manage its outcome with resolve and reject.

Press + to interact
const myTask = new Promise((resolve, reject) => {
// Simulate a task
const success = true; // Change to false to simulate an error
if (success) {
resolve("Task completed successfully!");
} else {
reject("Task failed.");
}
});

Explanation

  • Line 1: Defines a new Promise called myTask. Inside, two functions, resolve and reject, determine how to handle success and error cases.

  • Line 3: Sets a flag, success, to simulate whether the task will succeed. Changing this to false simulates an error.

  • Lines 5–6: If success is true, resolve is called with a success message, allowing any handler specified in .then(...) to be executed later.

  • Lines 7–9: If success is false, reject is called with an error message, which will be handled by .catch(...).

Handling Promises with .then and .catch

After creating a Promise, we can specify how to handle its outcome using .then(...) and .catch(...):

  • .then(...): Defines what happens when resolve is called, handling the successful outcome.

  • .catch(...): Defines what happens when reject is called, handling any error.

Let's continue with the previous example to see how to use them.

Press + to interact
const myTask = new Promise((resolve, reject) => {
// Simulate a task
const success = true; // Change to false to simulate an error
if (success) {
resolve("Task completed successfully!");
} else {
reject("Task failed.");
}
});
myTask
.then((message) => {
console.log(message); // Logs the success message from resolve
})
.catch((error) => {
console.error(error); // Logs the error message from reject
});

Explanation

  • Line 12: Invokes myTask and specifies how to handle the Promise's result with .then(...) and .catch(...).

  • Lines 13–15: If the Promise resolves successfully, the message passed to resolve is logged in .then(...).

  • Lines 16–18: If the Promise encounters an error, .catch(...) logs the error message from reject.

Note: Internally, promises work with the event loop similarly to callbacks: once a promise resolves or rejects, its result (or error) is placed in the appropriate callback queue.

Example: Converting a callback to a Promise

Here’s an example of reading a file with a callback.

Press + to interact
index.js
example.txt
const fs = require('fs');
console.log("Starting file read with a callback");
fs.readFile('example.txt', 'utf8', (err, data) => {
if (err) {
console.error("Error reading file:", err);
return;
}
console.log("File content:\n", data);
});

Explanation

  • Line 5: Begins reading a file asynchronously, with a callback to handle the result.

  • Lines 6–9: If there’s an error, it logs the error.

  • Line 10: If successful, it logs the file content.

Now, let’s convert this to use Promises.

Press + to interact
index.js
example.txt
// Using fs.promises to access Promise-based file operations
const fs = require('fs').promises;
console.log("Starting file read with Promises");
fs.readFile('example.txt', 'utf8')
.then(data => {
console.log("File content:\n", data);
})
.catch(err => {
console.error("Error reading file:", err);
});

Explanation

  • Line 2: The fs.promises import provides access to Promise-based file methods, like readFile and writeFile, allowing us to handle asynchronous file operations with .then() and .catch(). This differs from the standard fs module, which uses callback-based functions and does not support chaining with Promises.

  • Line 6: Begins reading the file and returns a Promise. Instead of a callback, we chain .then() to handle success and .catch() for errors.

Chaining Promises

When working with multiple asynchronous operations, Promises allow us to chain tasks together. However, how we structure the chain can significantly affect code readability and maintainability. Let’s compare two approaches to chaining Promises: a nested version and a cleaner, sequential version.

Here’s an example of handling two tasks—reading a file and writing its content to another file—using nested .then() calls:

Note: After executing the code, you can verify the contents of output.txt using the cat output.txt command.

Hello, Node.js learner!
This file demonstrates asynchronous reading with callbacks.
Happy coding!
Promise chaining example with file reading and writing

Explanation

  • Line 3: Initiates the first operation: reading the file example.txt.

  • Lines 7–9: Inside the first .then() block, it nests another .then() call for writing the content to output.txt.

  • Lines 10–12: Adds a separate .catch() block to handle errors for the write operation.

  • Lines 14–16: The outer .catch() block handles errors from the read operation.

To improve readability and maintainability, we can chain .then() calls sequentially, using a single .catch() to handle errors for the entire chain:

Hello, Node.js learner!
This file demonstrates asynchronous reading with callbacks.
Happy coding!
Handling multiple file reads with sequential .then() calls

Explanation

  • Line 3: Starts the first asynchronous task: reading example.txt.

  • Line 4–6: Processes the resolved content (data) and returns a new Promise from fs.writeFile. This ensures the second task is part of the same chain.

  • Line 7–8: Handles the resolution of the second task, logging a success message when the file is written.

  • Line 9–11: A single .catch() block handles errors for both the read and write operations.

Exercise: Handling asynchronous file operations

In this exercise, you’ll create a program using Promises to handle asynchronous file operations cleanly and efficiently.

Objective

Write a Promise-based code sequence that:

  • Reads input.txt asynchronously.

  • Processes the content by converting it to uppercase.

  • Writes the processed content to output.txt.

Instructions

  1. Open fileProcessor.js in the playground below.

  2. In fileProcessor.js, write code to:

    1. Read input.txt asynchronously.

    2. Convert the content to uppercase.

    3. Write the uppercase content to output.txt.

    4. Use .catch() to handle any errors that may occur.

Expected output

After running, output.txt should contain the uppercase content of input.txt. To view the contents, you can use the cat output.txt command in the terminal.

Hello, Node.js learner!
This file demonstrates asynchronous reading with callbacks.
Happy coding!
Using Promises to handle file operations without callback nesting

Try solving the problem on your own first. However, if you are feeling stuck, click the button below to see the solution.

Key takeaways

In this lesson, we’ve:

  • Recognized the challenges of callback hell and why Promises improve readability.

  • Defined Promises and learned the three states they can have.

  • Converted callback-based code to use Promises for a simpler structure.

  • Explored how to chain Promises to handle sequential tasks.

Access this course and 1200+ top-rated courses and projects.