[Roadmap_Node] 7_Asynchronous Programming in Node.js

Table of content

Introduction

In Node.js, asynchronous programming is a fundamental concept because Node.js itself is single-threaded. This means it can only handle one task at a time. However, asynchronous programming allows your application to appear responsive and handle multiple requests concurrently. Here’s a simplified introduction:

Benefits of Asynchronous Programming:

Keep in mind: Asynchronous programming can add complexity to your code. It’s important to manage asynchronous operations effectively to avoid issues like callback hell or unhandled promise rejections.

Callbacks

In Node.js, callbacks are a fundamental concept for handling asynchronous operations. Since Node.js is single-threaded, it can only execute one task at a time. But callbacks enable your application to appear responsive and manage multiple requests concurrently.

Here’s how callbacks work:

  1. Initiating an asynchronous operation: Imagine your application needs to read data from a file. You can’t wait for the entire file to be read before moving on, because that would block the main thread. So, you initiate the file reading process asynchronously using a function like fs.readFile.

  2. Providing a callback function: When calling the asynchronous function, you pass it a separate function as an argument. This function, called the callback, is what you want to happen after the asynchronous operation is completed. It’s like telling a friend, “Hey, go read that file, and when you’re done, call me back and tell me what you found.”

  3. Node.js continues execution: The main thread doesn’t wait for the file reading to finish. It can move on to handle other tasks while the file is being read in the background.

  4. Callback execution: Once the asynchronous operation (file reading) is complete, Node.js knows to call the callback function you provided. It’s like your friend coming back and telling you, “Alright, I finished reading the file, here’s the data.” The callback function receives any results or errors from the asynchronous operation as arguments.

Here’s a simplified example:

const fs = require("fs");

function readFile(fileName, callback) {
  fs.readFile(fileName, "utf8", (err, data) => {
    if (err) {
      callback(err); // Call the callback with the error
    } else {
      callback(null, data); // Call the callback with data (and no error)
    }
  });
}

readFile("myFile.txt", (err, data) => {
  if (err) {
    console.error("Error reading file:", err);
  } else {
    console.log("File content:", data);
  }
});

console.log("Meanwhile, the main thread can keep working on other tasks...");

Advantages of callbacks:

Disadvantages of callbacks:

While callbacks play a crucial role in Node.js, newer mechanisms like Promises and Async/Await have emerged to address some of these challenges and provide cleaner ways to manage asynchronous code.

Promises

Promises in Node.js are an improvement over callbacks for handling asynchronous operations. They offer a cleaner and more structured way to manage the eventual completion (or failure) of asynchronous tasks.

Key points about Promises:

Using Promises:

  1. Creating a Promise: You typically create a Promise using a Promise constructor function. This function takes an executor function as an argument. The executor function defines the asynchronous operation and has two arguments: resolve and reject.

  2. Resolving or rejecting: Inside the executor function, you use resolve to indicate successful completion and provide the result. Use reject to signal an error and provide an error object.

  3. Consuming a Promise: You use the then method to define what happens when the Promise is fulfilled (resolved). You can also use catch to handle potential rejections (errors). Both then and catch receive callback functions.

Here’s a basic example:

function readFilePromise(fileName) {
  return new Promise((resolve, reject) => {
    fs.readFile(fileName, "utf8", (err, data) => {
      if (err) {
        reject(err); // Reject the Promise with the error
      } else {
        resolve(data); // Resolve the Promise with the data
      }
    });
  });
}

readFilePromise("myFile.txt")
  .then((data) => {
    console.log("File content:", data);
  })
  .catch((err) => {
    console.error("Error reading file:", err);
  });

console.log("Meanwhile, the main thread can keep working on other tasks...");

Advantages of Promises over Callbacks:

Overall, Promises provide a more structured and manageable approach to asynchronous programming in Node.js compared to traditional callbacks.

Async/Await

Async/await is a powerful addition to JavaScript (introduced in ES2017) that simplifies asynchronous programming in Node.js. It provides a cleaner syntax that makes asynchronous code appear more synchronous, improving readability and maintainability.

Here’s a breakdown of async/await:

Using Async/Await:

  1. Declaring an async function: You mark a function as asynchronous using the async keyword before the function declaration.

  2. Using await: Inside an async function, you can use the await keyword before a Promise. The await expression pauses execution until the Promise resolves, and then the resolved value is available for further use in your code.

Here’s an example rewriting the previous Promise example using async/await:

async function readFileAsync(fileName) {
  try {
    const data = await fs.promises.readFile(fileName, "utf8");
    console.log("File content:", data);
  } catch (err) {
    console.error("Error reading file:", err);
  }
}

readFileAsync("myFile.txt")
  .then(() =>
    console.log("Meanwhile, the main thread can keep working on other tasks...")
  )
  .catch((err) => console.error("Unhandled error:", err)); // Optional error handling at the end

console.log("This line executes before the async function finishes.");

Advantages of Async/Await:

Important points to remember:

Overall, async/await offers a more elegant and streamlined approach to writing asynchronous code in Node.js compared to callbacks or even Promises alone.

Error first callbacks

Error-first callbacks, also known as “errorback”, “errback”, or “Node.js-style callbacks”, are a common pattern used for handling errors in asynchronous operations within Node.js. Here’s a breakdown of how they work:

The pattern:

  1. Function arguments: When using error-first callbacks, asynchronous functions typically take two arguments:

    • The first argument (usually named err) is reserved for errors that might occur during the operation. It will be null if there’s no error.
    • The second argument (often named data or result) is used to return the actual data or result of the successful operation.
  2. Callback execution: The asynchronous function calls the provided callback function when the operation completes.

    • If an error occurs, the callback is called with the error object as the first argument (err will have a value), and the second argument (data or result) will be null or undefined.
    • If the operation is successful, the callback is called with null for the error (err will be null) and the result data as the second argument (data or result).

Example:

function readFile(fileName, callback) {
  fs.readFile(fileName, "utf8", (err, data) => {
    if (err) {
      callback(err); // Call the callback with the error
    } else {
      callback(null, data); // Call the callback with data (and no error)
    }
  });
}

readFile("myFile.txt", (err, data) => {
  if (err) {
    console.error("Error reading file:", err);
  } else {
    console.log("File content:", data);
  }
});

Advantages of error-first callbacks:

Disadvantages of error-first callbacks:

Alternatives:

While error-first callbacks played a historical role in Node.js development, Promises and Async/Await are generally preferred for their improved readability and error handling capabilities when writing modern asynchronous code.

Showing an HTTP request with everything we learned

Here’s an example demonstrating how to perform an HTTP request using error-first callbacks, Promises, and Async/Await in Node.js:

1. Using Error-First Callback:

const https = require("https");

function makeRequest(url, callback) {
  https.get(url, (res) => {
    let data = "";

    res.on("data", (chunk) => {
      data += chunk;
    });

    res.on("end", () => {
      if (res.statusCode === 200) {
        callback(null, data);
      } else {
        callback(new Error(`Error: ${res.statusCode}`), null);
      }
    });

    res.on("error", (err) => {
      callback(err, null);
    });
  });
}

const url = "https://api.example.com/data";
makeRequest(url, (err, data) => {
  if (err) {
    console.error("Error:", err.message);
  } else {
    console.log("Response data:", data);
  }
});

Explanation:

2. Using Promises:

const https = require("https");

function makeRequestPromise(url) {
  return new Promise((resolve, reject) => {
    https.get(url, (res) => {
      let data = "";

      res.on("data", (chunk) => {
        data += chunk;
      });

      res.on("end", () => {
        if (res.statusCode === 200) {
          resolve(data);
        } else {
          reject(new Error(`Error: ${res.statusCode}`));
        }
      });

      res.on("error", (err) => {
        reject(err);
      });
    });
  });
}

const url = "https://api.example.com/data";
makeRequestPromise(url)
  .then((data) => console.log("Response data:", data))
  .catch((err) => console.error("Error:", err.message));

Explanation:

3. Using Async/Await:

const https = require("https");

async function makeRequestAsyncAwait(url) {
  try {
    const response = await new Promise((resolve, reject) => {
      https.get(url, (res) => {
        let data = "";

        res.on("data", (chunk) => {
          data += chunk;
        });

        res.on("end", () => {
          if (res.statusCode === 200) {
            resolve(data);
          } else {
            reject(new Error(`Error: ${res.statusCode}`));
          }
        });

        res.on("error", (err) => {
          reject(err);
        });
      });
    });
    console.log("Response data:", response);
  } catch (err) {
    console.error("Error:", err.message);
  }
}

const url = "https://api.example.com/data";
makeRequestAsyncAwait(url);

Explanation:

These are just basic examples. Remember to handle potential issues like timeouts or invalid URLs appropriately in your production code. Choose the approach that best suits your coding style and project requirements!

Conclusion

We finally learned some good concepts and put into action! We studied about callbacks, async/await, promises, and how they look doing the same action.

Its often suggested to use async/await over promises but your project should be consistent in its way of doing this (material for another post), for example, you should never do an API request using promises and then another one using async/await, your project needs to be consistent.

See you on the next post.

Sincerely,

Eng. Adrian Beria