Skip to main content

What are Callbacks, Promises, and Async and Await in Javascript?

In this lesson, we will discover what asynchronous JavaScript is and how to use callbacks, promises, and async and await.

Aakash Verma

Introduction

JavaScript is a single-threaded programming language, which means only one thing can execute at a time. While a single thread simplifies writing and reasoning about code, this also has drawbacks.

Imagine we perform a long-running task like fetching an image over the network. Now, we block the browser until the image is downloaded. This can lead to a bad user experience and might result in the user leaving our page.

That’s where asynchronous JavaScript comes into play. Using asynchronous JavaScript, we can perform long-lasting tasks without blocking the main thread.

There are three ways of doing asynchronous JavaScript:

  • Callbacks
  • Promises
  • Async/Await

Synchronous Code

When we execute code synchronously, we wait for it to finish before moving to the next task.

Code Example

function sayHello() {
  console.log('Hello');
}

console.log('Start');
sayHello();
console.log('End');

Output

Start
Hello
End

So, synchronous tasks must be aware of one another and executed in sequence.

Asynchronous Code

When we execute something asynchronously, we can move to another task before it finishes.

setTimeout() is a great example of an asynchronous code example. This allows us to wait a defined number of milliseconds before running its code:

Code Example

console.log('Start');

setTimeout(() => {
  console.log('Hello');
}, 1000); // Wait 1s to run

console.log('End');

Output

Start
End
Hello

This is because we are not blocking the code execution. Instead, we continue and come back to run the code inside setTimeout one second later.

Why do we need Callbacks?

Let's assume we want to fetch some data from a server, we can't return the result immediately. Fetching data from the server can take a few seconds. This means that the following wouldn’t work:

let response = getDataFromServer(); // fetch is asynchronous
let data = response.data();

This is because we don’t know how long it takes to fetch the data from the server. Therefore, when we run the second line, it throws an error because the response is not yet available. Instead, we need to wait until the response returns before using it.

Here is how you will correct this using Callbacks.

function getDataFromServer(callback) {
    setTimeout(() => {
        const responseData = { data: "Data from server" };
        callback(responseData);
    }, 2000); 
}

function handleResponse(response) {
    const data = response.data;
    console.log("Received data:", data);
}

getDataFromServer(handleResponse);  

Callbacks

This approach to asynchronous programming is to make slow-performing actions take an extra argument, called a callback function. When the slow action finishes, the callback function is called with the result.

For example: setTimeout

setTimeout(callback_function, time) : setTimeout takes two arguments. The first one is the callback function and the second is the duration after which this callback function should be executed.

setTimeout(() => console.log('One second later.'), 1000);

The above code snippet will print One second later. after 100 milliseconds.

💡
While the concept of callbacks is great in theory, it can lead to confusing and difficult-to-read code. Just imagine making callback after callback.

Code Example

readFile('input.txt', (readError, data) => {
  if (readError) {
    console.error('Error reading file:', readError);
    return;
  }
  
  processData(data, (processError, processedData) => {
    if (processError) {
      console.error('Error processing data:', processError);
      return;
    }
    
    writeFile('output.txt', processedData, (writeError) => {
      if (writeError) {
        console.error('Error writing file:', writeError);
        return;
      }
      
      console.log('Data processed and written to output.txt');
    });
  });
});

Nested callbacks that go several levels deep are sometimes called callback hell. Each new callback level makes the code more difficult to understand and maintain. Using callbacks is not common these days, but if we get unlucky, we might find them in legacy code bases.

Conclusion

When we have code that doesn’t complete immediately, we wait for it to finish before continuing. This is where asynchronous JavaScript comes in and callback is one of the solutions to achieve this.

Promises

Introduced with ES6, promises are a new way of dealing with asynchronous operations in JavaScript.

promise is an object that might produce values in the future. Just like in real life, we don’t know if the promise will be kept, and we use the promise object as a placeholder while we wait for the outcome.

The promise constructor takes one argument: a callback with two parameters, one for success (resolve) and one for fail (reject).

We need to either resolve a promise if it is fulfilled or reject it if it failed:

const promise = new Promise((resolve, reject) => { 

  // Do stuff

  if (/* fulfilled */) {
    resolve('It worked!');
  } else {
    reject(Error('It failed!'));
  } 
});

States

A promise in JavaScript is similar to a promise in real life. It will either be kept (fulfilled) or it won’t (rejected).

A promise can be:

  • pending – Initial state, not fulfilled or rejected yet
  • fulfilled – The operation succeeded, and resolve() was called.
  • rejected – The operation failed, and reject() was called.
  • settled – Has fulfilled or rejected
💡
After a promise is settled, it cannot change its state anymore.

Resolve

Let’s create a promise and resolve it:

const promise = new Promise((resolve, reject) => {
  resolve('Subscribe to Innoskrit');
});

console.log(promise);

Output

Promise {<fulfilled>: "Subscribe to Innoskrit"}

You can see that resolving the promise resulted in a fulfilled state.

Now that we have created a promise, let’s see how to use it.

Then

To access the value passed by the resolve or reject functions, we can use then()then() takes two optional arguments, a callback for a resolved case and another for a rejected one.

In this case, we get its resolved value by using the then() method:

const promise = new Promise((resolve, reject) => {
  resolve('Subscribe to Innoskrit');
});

promise.then((result) => console.log(result));

Output

Subscribe to Innoskrit

Note: A promise can only resolve or reject once.

Error Handling

When a promise rejects, the control jumps to the closest rejection handler. The .catch() doesn’t have to be immediately after. Instead, it may appear after one or multiple .then().

const promise = new Promise((resolve, reject) => {
  reject('It failed.');
});

promise
  .then((response) => response.json())
  .catch((error) => console.log(error));

Output

It failed.

Test yourself!

What is a promise?


Conclusion

Promises are commonly used when fetching data over a network or doing other kinds of asynchronous programming in JavaScript. They have become an integral part of modern JavaScript and, as such, are important for JavaScript users from developers to masters.

Async and Await

Async and Await act as syntactic sugar on top of promises. They allow us to write synchronous-looking code while performing asynchronous tasks behind the scenes.

Async

First, we have the async keyword. We put it in front of a function declaration to turn it into an async function.

async function getDataFromServer(url)

Invoking the function now returns a promise. This is one of the traits of async functions; their return values are converted to promises.

Async functions enable us to write promise-based code as if it were synchronous, without blocking the execution thread and instead operating asynchronously.

However, async alone does not make the magic happen. The next step is to use the await keyword inside the function.

Await

The real advantage of async functions becomes apparent when you combine them with the await keyword. Await can only be used inside an async block, where it makes JavaScript wait until a promise returns a result.

let val = await promise

The await keyword makes JavaScript pause at that line until the promise settles and returns its result, and then it resumes code execution.

It’s a more elegant syntax of getting the result from a promise than promise.then().

Fetch

fetch() allows us to make network requests similar to XMLHttpRequest (XHR). The main difference is that the Fetch API uses promises, which enables a simpler and cleaner API, avoiding callbacks.

The simplest use of fetch() takes one argument — the path to the resource — and returns a promise containing the response.

async getDataFromServer(url) {
  const data = await fetch(url);
  return data;
}

In our code, we wait for fetch() to return with the data before we return it from the function.

Now, we have our function ready. Remember, since it returns a promise, we need to use then() to get hold of the value.

getDataFromServer(url).then((data) => console.log(data));

Now, we have all the basics of expected behavior figured out, but what if something unexpected happens?

Error Handling

If await promise is rejected, it throws the error, just as if there were a throw statement at that line. We can catch that error using try/catch; this is the same way we catch an error in regular code.

async getDataFromServer(url) {
  try {
    const data = await fetch(url);
    return data;
  } catch(error) {
    // Handle error
  }
}

Conclusion

The async keyword is added to functions to tell them to return a promise rather than directly returning the value.

The await keyword can only be used inside an async block, where it makes JavaScript wait until a promise returns a result.

With fetch(), we can make network requests that return promises.

Test yourself!

How do you declare an async function to tell that the function returns a promise?.