Callback, Promise and Async/Await in JavaScript

Callback, Promise and Async/Await in JavaScript

(Updated: )

Callback, Promise and Async/Await are the ways in which asynchronous operation is done in JavaScript. In this post we will start by understanding the synchronous execution in JavaScript.

Then we will understand the process of asynchronous execution using Callback, Promise and Async/Await in JavaScript. We will understand the syntax of Callback, Promise and async/await with the example of a code snippet, and along the way we will also look at error handling, running multiple asynchronous operations together, and some common pitfalls to avoid.

Understanding the synchronous execution in JavaScript

JavaScript is a single threaded language and it works with the data structures like stack, queue and heap.

All the user actions or events has a message associated with them. When an event like a click event occurs a message (i.e the function that needs to be called on click) is first sent to the queue . There is a event loop between the queue and a stack. If the stack is empty the message is dequeued from the queue and pushed into the stack.

The stack is responsible for executing the code. Once the code is executed entirely another message is picked up by the event loop from the queue and pushed to the stack.

When there are lots of events triggered those are all lined up in the message queue and picked up by the event loop and executed sequentially.

This approach of execution is called synchronous approach. There is a wait time in processing the messages present in the queue. The next message cannot be picked up from the queue for processing until the current message is processed entirely.

This is the reason why we sometimes see the message 'The script you are executing is taking longer than expected to run. Click End to abort the script. or Continue to continue script execution.'

Asynchronous execution in JavaScript

We have seen the synchronous behaviour of JavaScript above. Synchronous execution would not be the right thing to do when in several scenarios.

One such scenario would be doing an I/O operation from the network like fetching data from database or streaming a video etc...

Since such I/O operation takes time to complete, all the messages in the queue would be in a waiting state giving a bad experience to the user.

That is where comes the role of asynchronous execution. In addition to the Stack, Queue and Heap there are also browser APIs that keep a track of callback function to be executed after the completion of the asynchronous operation and then let the stack continue with the execution of other messages from the queue.

Once the asynchronous operation is completed the APIs adds the callback function to the queue to be picked up by the event loop for execution in the stack.

Callback

A callback is a function that is passed as an argument to another function. This is basically done to achieve asynchronous behaviour in JavaScript.

An important point to note here is that not all functions that take another function as an argument is a callback function. It completely depends on how the callback function is called inside the containing function.
If the callback function is called inside the code block of asynchronous call it gets executed asynchronously.

Lets consider the below mentioned examples to better understand synchronous and asynchronous callbacks.

//Synchronous callback
const doSomeWork = (success, error) => {
  console.log("Do some work");
  try {
    //do something
    success();
  } catch (err) {
    error();
  }
  console.log("do another stuff");
};

const success = () => {
  console.log("Work completed successfully");
};
const error = () => {
  console.log("Work completed with errors");
};

doSomeWork(success, error);

Output
Do some work
Work completed successfully
do another stuff

The above code is an example of synchronous callback and the statements would execute sequentially.


//Asynchronous callback
const doSomeWork = (success, error) => {
  console.log("Do some work");
  setTimeout(() => {
    try {
      //do something
      success();
    } catch (err) {
      error();
    }
  }, 5000);
  console.log("do another stuff");
};

Output
Do some work
do another stuff
Work completed successfully

As seen in the output above the execution continues ahead without waiting for the asynchronous call to get completed.

Promise

As seen in the above code the callback pretty much does the job asynchronously but what if we need to perform multiple operations such that an operation can only start after the completion of previous operation. We may end up making nested callbacks which in a practical scenario becomes very complex to handle and this is what is called callback hell or a pyramid of doom.

//Callback hell
const files = [
  { name: "file1" },
  { name: "file2" },
  { name: "file3" },
  { name: "file4" },
];
const readFile = (file, callback) => {
  setTimeout(() => {
    console.log("processing ...", file.name);
    callback(file);
  }, 2000);
};
//Callback hell
readFile(files[0], function (file) {
  readFile(files[1], function (file) {
    readFile(files[2], function (file) {
      readFile(files[3], function (file) {});
    });
  });
});

Besides being hard to read, callback hell also makes error handling messy - each nested callback needs its own error check, and there is no single place to catch a failure that happened at any step of the chain. Promises solve both of these problems.

To avoid a pyramid of doom or callback hell we use Promise or async/await. Let us understand Promise in general terms first and then we will go through it technically. In real life when we make Promise for something there can be two things that can happen in future-

  1. We either keep the promise we had made by doing what was promised
  2. We do not keep the promise and we fail to do what was promised.

Similarly in JavaScript a Promise is an object which after a certain amount of time is either resolved by completing the task successfully or it rejected if the task is not completed successfully.
So a promise has two important functions resolve() and reject(). There are steps in the Promise -

  1. Creation of a Promise

    const doSomeWork = () => {
      console.log("Do some work");
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          try {
            //code to do some work
            resolve("Work completed successfully");
          } catch (err) {
            reject("Work completed with errors");
          }
        }, 2000);
      });
    };
    
  2. Using the Promise

doSomeWork()
  .then((res) => {
    console.log(res);
  })
  .catch((err) => console.log(err));

Mostly in all the front end and backend development we rarely create a promise but more often deal with using a Promise.
When a promise is completed successfully the value is returned from the resolve() method and it is received in the 'then' block while using the promise.
When a promise is not completed successfully due to some errors then the error is passed from the reject() method and it is caught in the catch block of the promise.

Promise have three states -

  1. Pending - This is the initial state of the promise before the beginning of an operation or when the promise is created.
  2. Fulfilled - This means that the specified operation was completed successfully.
  3. Rejected - This means that the operation did not completed successfully and a error is returned.

Promise have much more functionality and you can refer to the official documentation for the details. Promise

Chaining Promises

The .then() block itself returns a Promise, so multiple asynchronous steps can be chained one after another instead of being nested like we saw in the callback hell example. This solves the pyramid of doom problem while still keeping a single .catch() at the end to handle an error from any step in the chain.

To chain calls like this, readFile needs to return a Promise instead of taking a callback -

const readFile = (file) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("processing ...", file.name);
      resolve(file);
    }, 2000);
  });
};

readFile(files[0])
  .then((file) => readFile(files[1]))
  .then((file) => readFile(files[2]))
  .then((file) => readFile(files[3]))
  .catch((err) => console.log("something went wrong", err));

Running multiple Promises together

Quite often we need to kick off more than one asynchronous operation at the same time instead of one after another. JavaScript gives us a few static methods on Promise to combine multiple promises -

  1. Promise.all() - Takes an array of promises and resolves when all of them are resolved. It rejects as soon as any one of the promises is rejected, with the reason of the first rejection.
    Promise.all([readFile(files[0]), readFile(files[1]), readFile(files[2])])
      .then((results) => console.log("all files processed", results))
      .catch((err) => console.log("one of the files failed", err));
    
  2. Promise.allSettled() - Similar to Promise.all() but it waits for every promise to settle (either resolved or rejected) and never short-circuits. It is useful when you want the outcome of every operation regardless of whether some of them failed.
  3. Promise.race() - Resolves or rejects as soon as the first promise among the given promises settles, useful for scenarios like implementing a timeout for a network call.
  4. Promise.any() - Resolves as soon as any one of the given promises is fulfilled, and rejects only if all of them are rejected.

Async/Await

Async/Await is a syntactic sugar to the way of writing promises in a cleaner way. Async keyword indicates that a method is an asynchronous method and an await keyword is always written inside an async block however an async function can exist without having the await keyword.

const doSomethingAsync = async () => {
  console.log("doSomethingAsync : inside doSomethingAsync");
  const result = await doSomeWork();
  console.log(result);
  console.log("doSomethingAsync : continue with other work");
};
const doSomething = () => {
  console.log("doSomething : inside doSomething");
  const result = doSomeWork();
  console.log(result);
  console.log("doSomething : continue with other work");
};
const doSomeWork = () => {
  return new Promise((resolve, reject) => {
    console.log("work in progress");
    setTimeout(() => {
      try {
        resolve("work completed successfully");
      } catch (err) {
        reject("work completed with error");
      }
    }, 5000);
  });
};

doSomething();
doSomethingAsync();

In the above mentioned code there are two versions of the doSomething method doSomething() and doSomethingAsync() . doSomethingAsync() is an async version with an await operation. The async keyword just indicates that the method doSomethingAsync() is an asynchronous operation however the await keyword actually pauses the execution of that async function and the next statement does not get executed until the doSomeWork() method has completed its execution - it does not block the rest of the application, only the function it is written in.

In the doSomething() version the execution of code continues with the execution of next statement without even waiting for the doSomeWork() method to get completed.

Error handling with try/catch

Since await unwraps a Promise, a rejected Promise inside an async function throws an exception at the point of the await. This lets us use the familiar try/catch block instead of .catch(), which keeps error handling close to the code that can fail -

const doSomethingAsync = async () => {
  try {
    console.log("doSomethingAsync : inside doSomethingAsync");
    const result = await doSomeWork();
    console.log(result);
  } catch (err) {
    console.log("doSomethingAsync : something went wrong", err);
  }
};

Running multiple await calls one after another still executes them sequentially. To run independent asynchronous operations in parallel inside an async function, combine await with Promise.all() instead of awaiting each call one by one -

const processAllFiles = async () => {
  const results = await Promise.all(files.map((file) => readFile(file)));
  console.log("all files processed", results);
};

Common pitfalls to watch out for

  1. Forgetting to return a Promise from a .then() chain - if a step in the chain does not return the inner promise, the chain resolves before that step actually finishes, and any data from it is lost.
  2. Awaiting sequentially when operations are independent - writing await on unrelated calls one after the other makes them run one at a time instead of in parallel, which unnecessarily slows things down. Use Promise.all() for independent operations as shown above.
  3. Forgetting await inside an async function - this silently returns a pending Promise instead of the resolved value, and is a common source of bugs that are hard to spot since JavaScript does not throw an error for it.
  4. Not handling rejected promises - an unhandled rejection can crash a Node.js process or leave a fetch call failing silently in the browser. Always pair a .then() with a .catch(), or wrap await in a try/catch.
  5. Mixing callbacks and promises unnecessarily - once an API returns a Promise, prefer chaining or async/await over wrapping it back into a callback style, since it reintroduces the same readability problems Promises were meant to solve.

Callback vs Promise vs Async/Await

Callback Promise Async/Await
Readability Gets hard to read with nested calls Better, flat chaining with .then() Best, reads like synchronous code
Error handling Manual, per callback Centralized with .catch() Familiar try/catch
Running tasks in parallel Needs manual bookkeeping Promise.all() / allSettled() / race() / any() Same combinators, used with await
Underlying mechanism Plain functions Native Promise object Syntactic sugar over Promises

I hope I was able to explain the basics of Callback, Promise and Async/Await and the concept of synchronous and asynchronous programming in JavaScript.
You can refer to the code from my github repository - CallbackPromiseAsyncAwait Example

Book Recommendation

I would recommend you to refer to the book JavaScript - The Definitive Guide by David Flanagan for an in-depth understanding of the JavaScript Language.