beginner

Asynchronous JS: Callbacks, Promises and Async/Await


Asynchronous programming is a cornerstone of NodeJS, enabling efficient handling of tasks without blocking the main thread. This is important because we want code execution to be very fast. Imagine you are at a restaurant and someone orders a meal and you have to wait for that person to get the meal before you can order your meal. That is not optimized or fast at all. Similarly, we don’t want to wait for some other request to finish so that our request can be handled. Async code/execution solved that problem.

Callbacks

Callbacks were the initial approach for handling asynchronous operations in NodeJS. They allow you to pass a function as an argument to another function and execute it when a task completes.

function fetchData(callback) {
  // setTimeout first parameter is function that will execute after delay and second parameter is delay
  setTimeout(() => {
    const data = "Async data";
    callback(data);
  }, 1000);
}

fetchData((data) => {
  console.log(data); // This will print "Async data" after 1s(1000ms) has passed, as defined in setTimeout
});

Understanding code

First we have setTimeout that will execute first parameter(which is a function, in our case an arrow function) after a delay that is specified as a second parameter. With this, the below code will log “Hello” after 1000ms (1s)

setTimeout(() => {
  console.log("Hello");
}, 1000);

Instead of console.log we call a function callback.

function callback() {
  console.log("Hello");
}

setTimeout(() => {
  callback();
}, 1000);

but we want to pass the function as a parameter so we first put our setTimeout in a function fetchData and we make it have one parameter that we call callback. Then we call fetchData and pass in our myFunction(previously called callback) as a parameter. Keep in mind that we only passed the function (myFunction) but we didn’t call it(myFunction())

function myFunction() {
  console.log("Hello");
}

function fetchData(callback) {
  // setTimeout first parameter is function that will execute after delay and second parameter is delay
  setTimeout(() => {
    callback();
  }, 1000);
}

fetchData(myFunction);

Now we want to pass our own text to console.log so we make myFunction have one parameter that we pass it in our arrow function in first parameter of setTimeout

function myFunction(data) {
  console.log(data);
}

function fetchData(callback) {
  // setTimeout first parameter is function that will execute after delay and second parameter is delay
  setTimeout(() => {
    const myText = "Hello from setTimeout";
    callback(myText);
  }, 1000);
}

fetchData(myFunction);

Now instead of having a named function myFunction we want to instead have it as a anonymous arrow function. And as you can see, we have created a closures because we are using variables outside of the function scope.

function fetchData(callback) {
  // setTimeout first parameter is function that will execute after delay and second parameter is delay
  setTimeout(() => {
    const myText = "Hello from setTimeout";
    callback(myText);
  }, 1000);
}

fetchData((data) => {
  console.log(data);
});

Promises

Promises were introduced to tackle callback hell, a situation where multiple nested callbacks became hard to manage. Promises provide a more structured way to handle asynchronous operations and simplify error handling. To use promises we need to instantiate a Promise with a new keyword. This is part of classes and objects and will be covered in future posts. Promises come with two built-in callbacks resolve and reject.

The resolve function is used to fulfill a promise, indicating that the asynchronous operation has successfully completed and produced a result. When you call resolve with a value, that value becomes the fulfilled value of the promise. The reject function is used to indicate that an error or failure occurred during the asynchronous operation. When you call reject with an error object or an error message, the promise is rejected, and the specified error becomes the reason for the rejection.

function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const data = "Async data";
      resolve(data);
    }, 1000);
  });
}

fetchData()
  .then((data) => {
    console.log(data); // Since we called resolve function inside setTimeout this will execute
  })
  .catch((error) => {
    console.error(error); // If we were to call reject function inside setTimeout this would execute
  });

Async/Await

Async/await, built on top of promises, allows you to write asynchronous code in a more synchronous-looking manner, enhancing readability. In order to use await your function needs to have async. It is possible to do top level await meaning running await in global scope(outside of function) as of NodeJS 14. Its important that you don’t forget await before calling an async function(promise) because you will not get result back (you will get a pending Promise).

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      const data = "Async data";
      resolve(data);
    }, 1000);
  });
}

async function main() {
  try {
    const withoutAwait = fetchData();
    console.log(withoutAwait); // Promise {<pending>}
    const data = await fetchData();
    console.log(data); // Async data
  } catch (error) {
    console.error(error);
  }
}

main();

What now?

Now we are starting to work with more complex things so you really need to practice callbacks and promises and async/await because (almost) everything from now on will use those concepts.

Previous Understanding closures
Next Prototype-Oriented Programming(POP)