JavaScript's asynchronicity - Promises, callbacks and async/await

One of the core concepts of JavaScript is asynchronicity, which means doing many things simultaneously. It's a solution for avoiding your code being blocked by a time-intensive operation (like an HTTP request). In this article, you're going to learn the basic concept of asynchronicity and how to use it in JavaScript.


But before we start...

... we need to cover some computer theory. Programming is the process of telling your computer what it's supposed to do, we communicate with it using code. Every code is just a set of instructions for our machine that we want to execute. Every line of our code is executed by a so-called thread. A thread is executing only one instruction at a time. Let's analyze this pseudo-code:

set x to 10
set y to 5
add x to y save result to r
display r

When we execute this code, a thread is going to firstly set our variables x value to 10, THEN it will set y to 5, AFTER THAT it is going to add these two numbers together and save the result to our variable r and at the end it will display the value of r. The keywords are THEN and AFTER THAT, our thread can't simultaneously set x to 10 and y to 5, it has to wait with setting y until setting x is done.  This is type of code is named synchronous code - every instruction is executed one after another. With such simple operations, we're not going to find any issues, but what when we want to execute something that is time-consuming? Like downloading an image? Well, there's the tricky part.

Such an operation is a blocking code because it stops our thread from performing anything else until the image is downloaded. We don't want our users to wait every time such instruction occurs. Imagine downloading a meme and when it's happening your computer can't do anything else - your music player stops, desktop freezes, etc. - using such computers would be a pain. As you probably noticed, such things are not happening, you can listen to music, watch a video on YouTube and code your breakthrough project all at the same time. That's because computer engineers found a solution to this problem.

Wise people once thought, if one thread can execute one operation at a time, couldn't 16 threads execute 16 operations in parallel? Yes, they can - and that's the reason why modern CPUs have many cores and every core has many threads. Programs using many threads are multi-threaded.

The problem with JavaScript is that it's not multi-threaded, JavaScript is single-threaded, so it can't use many threads to do many operations at the same time. We're left with the same problem again - is there any other way to fix this? Yes! It's writing asynchronous code.

Let's assume that you want to fetch posts from your server every time your user scrolls your website. For this, we need to make an API call. API calls are just HTTP requests, which means that our browser making such call needs to establish a connection to our server, then our server processes the request, then sends it back, then our browser needs to process it... it's all time-consuming, and waiting for it to finish will block other interactions on our website, but it would only happen if our code was synchronous. Most time-consuming things like HTTP requests are mostly handled not by our main thread, but by lower-level APIs implemented in our browser. Asynchronous code uses this principle. We don't have to wait for our browser to finish the HTTP request, we can just inform the browser that we need to make an HTTP request, the browser will handle it and report to us with the result - in the meantime, other code can be executed on the main thread.

You probably noticed that asynchronous code is similar to multi-thread code. Well, kind of. Both help us to solve the problem with blocking code, but asynchronous code in JavaScript is pseudo-parallel. For example, if we want to run two compute-intensive calculations in parallel we can't do it until the execution is handled by something else (like a lower-level API of our browser). For real parallelism in JavaScript, we can use WebWorkers, which run specified code in the background. However, WebWorkers are not today's topic, so I won't talk about them - for now. 😉

Ok, that's enough theory. How we can write this asynchronous code in JavaScript? There are two major ways to do it, the older method using callbacks and the newer method using Promises. It's time to look at them in deep.

Callbacks

Earlier I said that when our asynchronous operation is done, we inform our main thread about it. The older way to report back is using a callback. A callback is basically a function that is called when our task is done. It can also carry arguments with data like a result of the asynchronous task. Let's analyze some examples.

We are going to fetch information about Charmander from pokeapi.co using XMLHttpRequest API.

const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://pokeapi.co/api/v2/pokemon/charmander', true);
xhr.responseType = 'json';
xhr.onload = (e) => {
  if (xhr.status === 200) {
    console.dir(xhr.response);
  } else {
    console.error('Something went wrong...');
  }
};
xhr.send(null);

The first 3 lines are just configuring the XMLHttpRequest object. The thing that interests us the most is xml.onload, because here we specify our callback using an arrow function. When we send our request, the browser is going to handle it and when it's done it's going to call our callback function in which we can further process the received data.

Another common example of using callbacks to handle asynchronous tasks are Event Listeners. Look at the code below.

const button = document.getElementById('myButton');
button.addEventListener('click', (event) => {
  console.info('Button clicked!');
});

We get our button element using its ID, then we attach a listener to its click event. Listener functions are nothing else than just callbacks. Our arrow function is called every time the user clicks this button. This whole process is not blocking code, because we don't have to wait for the click in our main thread. Events are handled by the browser and we only attach a callback that is called when the click is done.

One more example. Timeout and Intervals are also asynchronous.

const timeout = setTimeout(() => {
  console.info('Boo!');
}, 5000);

The Timeout or Interval handler function is also a callback and it's called only after a certain time has been deducted. The whole time measurement code is handled by our browser's components, not by us, so we are only informed when the right amount of time has passed.

Now let's combine some of these examples as a recap.

const button = document.getElementById('myButton');
button.addEventListener('click', (event) => {
  console.info('Button clicked!');
});

const request = setTimeout(() => { // This timeout is going to simulate a very long HTTP request
  console.info('Response received!');
}, 5000);

In this code, we attach a listener to our button and make an HTTP request. If you run this example, you can see that you can click the button despite the fact that the HTTP request is being made. You don't have to wait with the request until the button is clicked, nor do you have to wait with handling the button click until the HTTP request is done - no operation is blocked. That's the power of asynchronicity!

Promises

The modern way to handle asynchronicity in JavaScript is to use Promises. You can think of them like a promise made by people. It's not the result of something, it's just a promise that something will be done in the future (or not). If your roommate promises you to take out the trash this week, she's telling you that she will do it in the future, but not now. You can focus on your things and after some hours your roommate is going to tell you that the trashcan is empty and that she fulfilled her promise. Your roommate can also tell you, that she couldn't fulfill it because there is a raccoon living in your trashcan and it behaves aggressively when you try to take out the litter bag. In this case, she couldn't keep this promise, because she doesn't want to be attacked by an aggressive raccoon.

Remember, not every raccoon is aggressive! Photo by Vincent Dörig on Unsplash

A Promise can be in one of three states:

  • pending - This is an initial state, the Promise is running and we don't know if it's fulfilled or something went wrong.
  • fulfilled (or resolved) - Everything is ok. The Promise has completed its task successfully.
  • rejected - Something went wrong and the operation failed.

So let's create our first promise.

const promise = new Promise((resolve) => {
  setTimeout(resolve, 3000);
});

We are creating a new Promise object by calling the Promise constructor. As you can see in this example the constructor of a Promise object takes an arrow function as an argument. This argument is called an executor or executor function. The executor is going to be called when we are creating our Promise object and it is the connector between your Promise and the result. The executor takes two arguments a resolve function and a reject function - both of them are used to control your Promise. Resolve is used to mark our promise as fulfilled and return result data. Reject is used to notify that something is wrong and the Promise is not going to be fulfilled - it's rejected. Reject like resolve can also carry data, in most cases, it carries information about why the Promise was not fulfilled.

Resolving and rejecting promises can be handled by methods, provided by the Promise object. Take a look at this code.

const promise = new Promise((resolve) => {
  setTimeout(resolve, 3000);
});

promise.then(() => {
  console.info('3 seconds have passed!');
});

Our promise is very simple, our executor is going to create a Timeout and call our resolve function after 3 seconds. We can intercept this information using .then() by providing a callback to it. .then() takes two arguments, the first is a callback called, when the Promise is fulfilled, the second one (not seen in this example) is a callback called when the Promise is rejected. But for handling rejected promises we can use a more convenient method - .catch(). Let's modify our example.

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const number = Math.floor(Math.random()*100);

    if (number % 2 === 0) {
      resolve(number);
    }

    reject(new Error('Generated number is not even!'));
  }, 3000);
});

promise.then((result) => {
  console.info('Promise fulfilled!');
  console.info(`${result} is even.`);
}).catch((error) => {
  console.info('Promise rejected!');
  console.error(error);
});

This code after 3 seconds is going to generate a random number and check if it's even or not. If it's even then the Promise is resolved and we return the even number, if not, we reject the Promise with an error message. .catch() as an argument accepts a callback that is called when the Promise is rejected.

We can also reject Promises by throwing an error.

const promise = new Promise((resolve) => {
  throw new Error('Error message');
});

promise.then((result) => {
  console.info('Promise fulfilled!');
}).catch((error) => {
  console.info('Promise rejected!');
  console.error(error);
});

However, this has some limitations. If we throw an error inside an asynchronous function like Timeout's callback in our example, .catch() will not be called and the thrown error will behave as an Uncaught Error.

const promise = new Promise((resolve) => {
  setTimeout(() => {
    const number = Math.floor(Math.random()*100);

    if (number % 2 === 0) {
      resolve(number);
    }

    throw new Error('Generated number is not even!'); // This is an Uncaught Error
  }, 3000);
});

promise.then((result) => {
  console.info('Promise fulfilled!');
  console.info(`${result} is even.`);
}).catch((error) => {
  console.info('Promise rejected!');
  console.error(error);
});

Also, you need to remember that every error thrown after calling resolve() is going to be silenced.

const promise = new Promise((resolve) => {
  resolve();
  throw new Error('Error message'); // This is silenced
});

Beside .then() and .catch() we also have a third method - .finally(). Finally is called when the Promise is done, it doesn't bother if it was resolved or rejected, it runs after .then() and .catch().

const promise = new Promise((resolve, reject) => {
  if (Math.random() < 0.5) {
    resolve('Promise fulfilled');
  }

  reject(new Error('Promise rejected'));
});

promise.then((result) => {
  console.dir(result); // Runs only when the Promise is resolved
}).catch((error) => {
  console.error(error); // Run only when the Promise is rejected
}).finally(() => {
  console.dir('Promise has finished its work'); // Run everytime the Promise is finished
});

Now, let's analyze a real-case example.

const fetchCharmanderData = fetch('https://pokeapi.co/api/v2/pokemon/charmander');

fetchCharmanderData.then((response) => {
  if (response.status === 200) {
    return response.json();
  } else {
    throw new Error(response.statusText);
  }
}).then((data) => {
  console.dir(data);
}).catch((error) => {
  console.error(error);
});

This code will fetch information about Charmander from pokeapi.co but it uses the new, promise-based fetch API. Fetch will make an HTTP request and return a Promise for it. When the data is fetched, we process the response. If we received a HTTP status 200 (OK) we are returning the JSON representation of the response body, if the status code is different (like 404 not found or 500 internal server error) we throw an error with a status message. As you see, we are using .then() twice. The first time is used, as I mentioned, to process the response, the second time we use .then() to process a second Promise. response.json() also returns a Promise (JSON parsing can also take some time so it can also be blocking code, that's why we want to make it asynchronous). Basically, this proves to us that you can have a Promise that resolves another Promise and you can handle them one after another by chaining control methods like then, catch and finally.

async/await

Chaining .then(), .catch() and .finally() can be sometimes painful and lead to the creation of hard-to-read code. ES8 (or EcmaScript 2017) introduced some syntax sugar for easier handling promises - async and await. Let's rewrite our Charmander example using async/await.

(async () => {
  const response = await fetch('https://pokeapi.co/api/v2/pokemon/charmander');

  try {
    if (response.status === 200) {
      const charmanderData = await response.json();
      console.dir(charmanderData);
    } else {
      throw new Error(response.statusText);
    }
  } catch (error) {
    console.error(error);
  }
})();

This code does exactly the same which is done by the previous code - it's only written in a different way. We can't use await outside of asynchronous functions, so we are bypassing it by creating a self-calling async function. Inside this function, we are waiting for the response returned by fetch(). After we receive the response we are going to check its status code, when it's OK we await for the response body to be parsed and after that, we are going to output it. You probably noticed the missing of .catch(). We replaced it with a try-catch block, basically, it is going to do the same thing as .catch(). If anything inside try throws an error the code will stop to execute and the error handling code inside catch will be run instead.

I mentioned async functions and that await can be used only inside them. It is a new type of functions introduced in ES8, and, simplifying, it's a function that utilizes Promise-based behavior, which means that an async function always returns a Promise. It can be then awaited in another async function or treated like a Promise.

async function getCharmanderData() {
  const response = await fetch('https://pokeapi.co/api/v2/pokemon/charmander');
  return response.json();
}

(async () => {
  console.dir(await getCharmanderData());
})();
NOTE. This example was simplified, and no exception handling is now included.

We moved our logic that is responsible for fetching Charmander's data from pokeapi.co to an async function. After this, every time, when we need that data we can simply call this function with await and we can deal with it without writing long promise chains.

I said that an async function can be treated like a Promise, and here is an example of how we can do this.

async function getCharmanderData() {
  const response = await fetch('https://pokeapi.co/api/v2/pokemon/charmander');
  return response.json();
}

getCharmanderData().then((data) => {
  console.dir(data);
});

Await can also be used on normal functions that return a Promise.

function delay(time) {
  return new Promise((resolve) => {
    setTimeout(resolve, time);
  });
}

(async () => {
  console.info('Start!');
  await delay(5000);
  console.info('5 seconds have passed.');
})();

Promise helpers

The Promise object has also some pretty useful methods that can help us with handling many Promises.

Promise.all()

Promise.all() awaits for all passed Promises to be fulfilled and resolves all results to an array.

const charmander = fetch('https://pokeapi.co/api/v2/pokemon/charmander').then((response) => response.json());
const bulbasaur = fetch('https://pokeapi.co/api/v2/pokemon/bulbasaur').then((response) => response.json());
const squirtle = fetch('https://pokeapi.co/api/v2/pokemon/squirtle').then((response) => response.json());

Promise.all([charmander, bulbasaur, squirtle]).then((result) => {
  console.dir(result);
});

Worth mentioning is the fact, that when one of the passed promises is rejected Promise.all() is also rejected.

Promise.allSettled()

It's similar to Promise.all() but it's not rejected when one (or more) of the passed promises is rejected.

const charmander = fetch('https://pokeapi.co/api/v2/pokemon/charmander').then((response) => response.json());
const fail = fetch('https://pokeapi.co/api/v2/pokemon/non-existing').then((response) => response.json()); // This Promise is going to fail
const squirtle = fetch('https://pokeapi.co/api/v2/pokemon/squirtle').then((response) => response.json());

Promise.allSettled([charmander, fail, squirtle]).then((result) => {
  console.dir(result);
});

Promise.any()

Promise.any() is fulfilled when any of the passed Promises is fulfilled. It is also going to return the result of the first resolved Promise. When none of the passed promises is fulfilled Promise.any() is going to be rejected.

const charmander = fetch('https://pokeapi.co/api/v2/pokemon/charmander').then((response) => response.json());
const bulbasaur = fetch('https://pokeapi.co/api/v2/pokemon/bulbasaur').then((response) => response.json());
const squirtle = fetch('https://pokeapi.co/api/v2/pokemon/squirtle').then((response) => response.json());

Promise.any([charmander, bulbasaur, squirtle]).then((result) => {
  console.dir(result);
});

Promise.race()

It is resolved when any of the passed promises is resolved or rejected.

const charmander = fetch('https://pokeapi.co/api/v2/pokemon/charmander').then((response) => response.json());
const bulbasaur = fetch('https://pokeapi.co/api/v2/pokemon/bulbasaur').then((response) => response.json());
const squirtle = fetch('https://pokeapi.co/api/v2/pokemon/squirtle').then((response) => response.json());

Promise.race([bulbasaur, charmander, squirtle]).then((result) => {
  console.dir(result);
});

Now you should have a better understanding of JavaScript's asynchronicity. As homework try to play with pokeapi.co and the Fetch API. Create custom Promises that are going to fetch Pokemons after a certain delay or Fetch data based on something you received in an earlier Promise. You can also use async/await and Promise helpers in your code to experiment even more with this topic. See you (or read you?) and happy coding!