Asynchronous JavaScript: Event Loop, Callbacks, and Promises

by Nicklas Envall

JavaScript is a single-threaded programming language. Meaning, it can do one thing at a time. Having only one thread avoids a lot of complexity that comes from multithreading. However, if we synchronously do something that will take quite some time, our thread will be blocked for quite some time. Imagine the following pseudocode:

button.addEventListener("click", function () {
   var data = syncRequestData(); // always takes 10 seconds
   console.log(data);
});

We cannot execute another function until the requested data is returned, which leaves the client frozen. What this means is that if a user clicks the button, then they cannot interact with the web app for 10 seconds.

If we would've used an asynchronous approach for requesting the data, our user will still be able to interact with the website while the request is handled in "the background" until the data is returned. So with the asynchronous approach, the user can still interact with the website because we allow JavaScript to keep executing code.

But JavaScript can only do one thing at a time, so how can we write asynchronous JavaScript code? That's what we'll look at in the following sections:

  • The Event Loop
  • What is a Callback in JavaScript?
  • What is a Promise in JavaScript?
  • Asynchronous and Promise Analogy

The Event Loop

Firstly we must understand that the JavaScript Engine does not work solo it operates in a hosting environment. A hosting environment can be a browser, a Node.js server, etc. These environments all have something called an event loop. To understand the event loop, we will look at how an environment looks like by concisely breaking it up into three parts:

  1. JavaScript Engine
  2. APIs & Event Table
  3. Callback Queue

This section assumes you know, how stacks and queues work. So if you want, you can refresh your memory with a previous article I wrote, called Stack and Queue in Javascript. Anyways, let's get started, in the image below we see how it roughly looks like in the browser.

Event Loop for JavaScript in the Browser

1. JavaScript Engine

The Engine has two main components, a Call Stack and a Memory Heap. JavaScript is single-threaded and therefore has only one Call Stack. The Memory Heap is where memory allocation occurs.

The Call Stack utilizes a data structure called "stack" to keep track of functions being run. It tracks where it is and where it was, so it knows where it should go back to. More specifically, it tracks what current function is being executed and where to return to. The reason for this is to keep track of execution contexts, which we will not cover here. What we need to know about the Call Stack right now is that we only have one, and can therefore only execute one thing at a time.

Side note: I wrote an article on whether or not JavaScript is interpreted or compiled, which concisely looks more on why we use an engine and how.

2. APIs & Event Table

APIs can be provided by browsers, Node.js, etc. These APIs are not a part of the JavaScript language, but they are communicated with, via our JavaScript code. For example, if we look at the list of Web APIs at MDN, we can see that the DOM is not in the JS Engine, which also is the case for AJAX and setTimeout.

When we use these APIs, we utilize an event table. The event table keeps track of callbacks for their respective events. Once an event occurs, ie an asynchronous operation is completed, then the callback of that event gets enqueued into the callback queue.

If we focus at JavaScript in browsers, then user actions create events from mouse clicks, scrolling, etc. But an event can also, for example, be a setTimeout like this:

setTimeout(function cb1 () { 
    console.log("hello");
}, 200);

Let's break down what would happen here. setTimeout would firstly get pushed and popped off the Call Stack, and since it leighs in a Web API, it would have been moved to the event table, where it would wait until the event occurs (after 200ms). Then the cb1 function would have been moved to the callback queue.

3. Callback Queue

The Callback Queue is also known as, Message Queue or Event Queue (confusing with so many names). With this queue, we have the callbacks from the events all queued up. The functions are waiting to be dequeued and put on the Call Stack.

Now, let's go back to the event loop. The event loop keeps track of the Call Stack and Callback Queue. Remember that JavaScript is single-threaded and can only do one thing at a time. The event loop understands this and makes sure that the callbacks from the Callback Queue are added to the Call Stack, once the Call Stack is empty.

What is a Callback in JavaScript?

A function used as an argument in another function is a callback. The function passed as an argument is "called back to" inside another function, hence the name callback.

We can use callbacks synchronously and asynchronously. The synchronous approach would be to invoke the callback before the function it's passed to returns. The asynchronous approach would be to invoke the callback after the function it's passed to has returned.

An example of a synchronous approach with a callback:

function myName() {
    console.log("my name is");
}

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

hello(myName);
// hello
// my name is

In the example above we pass the function myName as an argument to hello, followed by calling the myName inside hello before returning hello.

Callbacks are more commonly used when we read files, request something from a database, etc. Which we, for example, do by using the Web APIs (see section "The Event Loop"). Let's for simplicity sake refer to these as slow functions. We can attach callback functions for these slow functions. When a "slow function" finishes, its callback function will get called. Let's try it out by creating an asynchronous callback:

setTimeout(function printA () {
    console.log("A");
}, 1000);

console.log("B");
// B 
// A

This example is similar to the example in the section APIs & Event Table. By using the words "now" and "later", we execute the setTimeout function now, and the printA function later.

As we should be aware of now, console.log("B") will be pushed on to the Call Stack before the printA function. The reason for this being that the printA function will be added to the Callback Queue before reaching the Call Stack. Therefore, this example would get the same result even if we changed 1000ms to 0ms.

However, using setTimeout is, of course, not the only scenario where we use callbacks. Most often we use callbacks when we execute AJAX requests, due to its asynchronous nature. All reasonable AJAX requests return data asynchronously, and that means we cannot write synchronous code like (ajax.get is for illustration purposes only):

var data = ajax.get("url");
console.log(data); // undefined;

But now when we are aware of the event loop and how we can use callbacks with an asynchronous function. We could instead use a callback like:

ajax.get("url", function(data) {
    console.log(data);
});

Callback Hell

Callback Hell (sometimes called pyramid of doom) occurs when we start to nest callbacks like:

do1(function(a) {
  do2(b, function(c) {
    do3(c, function(d) {
        // etc
    });
  });
});

Callback Hell decreases the readability of the code and makes it harder to maintain. There are ways to avoid Callback Hell, one of them is, using promises. If you want to find out more, then there are plenty of resources online on how to avoid Callback Hell.

Error Handling with Callbacks

Most often, when handling errors for callbacks, we use the "error-first callbacks style" (also known as "Node style"). The first argument is an error if it's not null, an error has then occurred. If the function successfully did as it was supposed to do, then the err will be null.

fs.readFile('/file.txt', function(err, data) {
    if (err) {
        // do something
    }
    // else do something with data
    console.log(data);
});

Imagine error handling, and on top of that, Callback Hell, it quickly gets complex.

What is a Promise in JavaScript?

In JavaScript, a promise is an object that symbolizes the future value of an asynchronous operation. Essentially, it's a promise for a future value, which we can use in our code in a way as if we already had the actual value.

We create a promise with the new keyword, new Promise(executor). A promise takes one argument which must be a function. The executor function itself has two arguments which both are callbacks, resolve and reject. If we succeed, we use resolve(value), if we fail we use reject(error). When we use resolve or reject we change the state of the promise. We create callbacks for each possible state change (triggered by resolve or reject) that will handle the returned value.

A promise may be in one of the following three states:

  • pending: not yet rejected or fulfilled
  • fulfilled: resolve() was called
  • rejected: reject() was called
new Promise((resolve, reject) => {});
// { <state>: "pending" }

In the example above, we created a new promise object, which is in the state "pending" because we had neither called resolve or reject. Now in the next example we'll use resolve:

let p = new Promise((resolve, reject) => {
	setTimeout(() => resolve("im fulfilled"), 1000);
});
console.log(p); // { <state>: "pending" }

// We wait around 1 second and manually do
console.log(p); // Promise { <state>: "fulfilled", <value>: "im fulfilled" }

In the example above, the promise changed its state to "fulfilled" after being resolved inside the executor.

Side note: one important thing to know is that once the promise leaves the "pending state" its state cannot be changed again. Such a promise is settled (has become resolved or rejected).

Promise Methods: then, catch, and finally

Now we'll look at then, catch and finally. They are all methods with attached callbacks that return a promise. These methods are called once a promise's state becomes fulfilled or rejected, so they may be used to handle the fulfilled or rejected value.

1. then

Promise.prototype.then takes one or two arguments which both are callbacks, I will refer to these callback functions as, onFulfilled and onRejected we can view it as then(onFulfilled, onRejected).

let p = new Promise(function(resolve, reject) {
    resolve('promise was resolved');
});

p.then(
    function onFulfilled(value) {
        console.log(value);
    },
    function onRejected(error) {
        console.log("ERROR: " + error);
    }
);

// "promise was resolved",

2. catch

Promise.prototype.catch takes one argument, which is a callback catch(onRejected). According to the ECMAScript spec, this method invokes then(undefined, onRejected) behind the scenes.

let p = new Promise(function(resolve, reject) {
    reject('123');
});

p.then(value => console.log(value))
 .catch(err => console.log("ERROR: " + err));

// ERROR: 123

3. finally

Promise.prototype.finally invokes when the promise is settled. This means that it does not matter if the promise gets resolved or rejected, finally will be invoked either case.

let p = new Promise(function(resolve, reject) {
    // I recommend trying with resolve aswell
    reject('123'); 
});

p.finally(() => console.log("hello"));

// hello

Promise Chaining

then() creates and returns a new promise which allows us to create an asynchronous sequence. This sequence helps us avoid having to use nested callbacks, like:

getProductId(function(id) {
    getProduct(id, function(product) {
        sendProduct(product);
    });
});

Instead, we could have done the following with promise chaining:

getProductId()
.then(id => getProduct(id))
.then(product => sendProduct(product));

Error Handling with Promises

We briefly covered error handling above. This section will provide a good base for error handling with promises, but there are more things to know about error handling than we'll cover, which also goes for the entire article, do keep that in mind.

Anyways, promises are great, but we must be aware of the following:

let promise = Promise.resolve(3);

promise.then(
    function onFulfilled(value) {
        throw Error("i will not be caught");
    },
    function onRejected(error) {
        // never gets here
    }
);

// Uncaught (in promise) Error: i will not be caught ...

What is the problem here? What happens if an error would occur in the onFulfilled function, shouldn't onRejected catch it? The answer is, no, because those callbacks are only invoked when the promise changes state, and in this case, it became fulfilled not rejected.

To avoid our errors going into the abyss, we can use .catch:

let promise = Promise.resolve(3);

promise.then(
    function onFulfilled(value) {
        throw Error("i will be caught");
    },
    function onRejected(error) {
        // never gets here
    }
)
.catch(error => console.log("I CAUGHT: " + error));

// I CAUGHT: Error: i will be caught

A great thing with .catch is that if a promise gets rejected in a chain, then the closest .catch will get invoked:

let promise = Promise.resolve(3);

promise
.then(x => x + 1)
.then(x => x.toUpperCase() * 2) // Error is thrown here
.then(x => console.log(x))
.catch(error => console.log("I CAUGHT: " + error)); // But caught here

// I CAUGHT: TypeError: x.toUpperCase is not a function

Asynchronous and Promise Analogy

I saved the analogies for the end, which is quite unusual, but I wanted the text above to be straight to the point. So now you can read these two analogies if you want.

Asynchronous Analogy

Imagine a waiter that has received an order from some customers. The waiter has written down the order and hands it to the chef. Should the waiter do option A, just stand there and wait until the chef has cooked the meals? Or should the waiter do option B, continue to take other people's orders, while the chef cooks the meals?

Well, option A would be a synchronous approach which would lead to multiple people having to wait to make their order. Option B, on the other hand, would be an asynchronous approach which would be a better approach than option A because it would allow the waiter to continue to take people's orders even though the chef has not finished the meals.

Promise Analogy

Imagine that your friend promises you that they will buy you a t-shirt. You have not received the t-shirt yet, but your friend made you a promise, which means that you can start to reason about the shoes or pants you'll wear with the t-shirt once you get it. But it's only a promise, and your friend might not be able to fulfill the promise. Later your friend will either give you a t-shirt or a reason as to why they couldn't give you a t-shirt.