How JavaScript Works: A complete guide to asynchronous JavaScript

Lawrence Eagles
SessionStack Blog
Published in
11 min readAug 18, 2022

This is post # 71 of the series, dedicated to exploring JavaScript and its building components. In the process of identifying and describing the core elements, we also share some rules of thumb we use when building SessionStack, a JavaScript tool for developers to identify, visualize, and reproduce web app bugs through pixel-perfect session replay.

Introduction

Asynchronous JavaScript is one of the essential parts of the language because it governs how we handle long-running tasks — such as fetching data from a server or an API.

In a simplistic nutshell, we can look at asynchronous code as code that starts a task now and finishes it later. We will elaborate on this as we move on in the article but before then, let’s learn about synchronous code — the counterpart to asynchronous code.

JavaScript, by nature, is a synchronous language. And this means that JavaScript can execute only one code at a time — from top to bottom.

Consider the code below:

console.log(“logging line 1”);

console.log(“logging line 2”);

console.log(“logging line 3”);

By default, JavaScript executes the code above synchronously. And this means line by line. So line 1 cannot be executed before line 2, and line two cannot be executed before line 3.

Also, JavaScript is called a single-threaded language. And this essentially means the same thing as JavaScript being a synchronous language —by nature.

A thread is like an ordered sequence of statements as seen in the image below:

In a thread, only one of those statements can run at a given time. And this is the crux of synchronous code: a single thread and one statement being executed at a time.

You can learn more about threads in our previous article in this series.

So because in synchronous code, only one statement can run at a time, synchronous code is referred to as blocking code.

To elaborate on this, let us assume statement 2 in the image above is a long-running task such as a network request to a server. The result of this is that statements 3 and 4 cannot be executed until the execution of statement 2 is completed. Hence the synchronous code is referred to as “blocking code”.

Now, from our understanding of synchronous code, we see that if we have multiple statements — functions in a thread that perform long-running tasks, then the rest of the code below these functions is blocked from running until these functions complete their tasks.

This pattern can negatively affect the performance of our program. And this is where asynchronous code comes in.

As noted above, asynchronous code is code that starts a task now and finishes later. And by this, we mean when an asynchronous function that handles a long-running task is executed in a thread, the browser moves the long-running task away from that thread and continues processing it. Also, the browser simultaneously continues executing other functions in that thread but adds a callback function to the thread. Thus asynchronous code does not block the flow of execution — so they are referred to as non-blocking code.

When the long-running task is completed, a callback function is called when the other functions in the main thread finish executing. And this callback function handles the data returned from the long-running computation.

Consequently, the asynchronous programming pattern enables our program to start a long-running task and still continue the execution of other tasks in the thread. So we do not have to wait until that long-running task has finished.

Let‘s elaborate on this with some code examples.

Consider the synchronous code below:

Consider the asynchronous code example below:

In the code above, the synchronous code executed each statement sequentially. But in the asynchronous code example, the code execution was not sequential.

In the asynchronous code example, we used the setTimeout function to simulate a long-running task that takes two seconds to complete. Consequently, statement 2 is printed last to the console because the flow of execution is not blocked. Thus other statements were being executed.

Following this introduction, we will take a deep dive into asynchronous programming in JavaScript.

Let’s get started in the next section.

Getting Started

In the introduction, we worked with a small contrived example of asynchronous code. But in this section, we will go deeper by using network requests in place of setTimeout functions. And for this, we need to understand some concepts like HTTP requests.

HTTP Requests

Sometimes we want to show data such as blog posts, comments, a list of videos, or user data stored on a database or remote server on our website. And to get this data, we make HTTP requests to the external server or database.

HTTP requests are made to API endpoints — URLs exposed by APIs. And we interact with these endpoints to perform CRUD operations — reading, creating, updating, or deleting data.

In this article, we will work with endpoints from JSONPlaceholder. And in the next section, we will learn about asynchronous programming patterns used to handle network requests in JavaScript.

Asynchronous programming patterns

Asynchronous programming patterns in JavaScript have evolved with the language. And in this section, we will learn how asynchronous functions have historically been implemented in JavaScript. We will learn about asynchronous programming patterns such as callbacks, Promises, and the Async-await.

Also, we will learn about making a network request with the XMLHTTPRequest object and the Fetch API.

Making HTTP Requests With The XMLHttpRequest Object

The XMLHttpRequest object is an asynchronous API that enables us to make a network request to an endpoint or database. The XMLHttpRequest API is an old asynchronous JavaScript pattern that uses events.

Event handlers are a form of asynchronous programming — where the event is the asynchronous or long-running task, and the event handler is the function that is called when the event occurs.

Consider the code below:

prints a list of posts as seen in the image below:

Note, to use the code above in a Nodejs environment you will need to install a package such as node-XMLHttpRequest.

In our example above, the XMLHttpRequest object uses an event listener that listens for the readystatechange event. And when this event fires, the event handler is called to handle the event. You can learn all you need to know about events and event handlers by reading our previous article in this series here.

Asynchronous programming With Callbacks

In the code above, whenever we reuse the getPosts function, we print the fetched posts to the console. However, we can make further computations with the result of the getPosts functions by using several asynchronous programming patterns. And the first pattern we will learn about is the callback pattern.

A callback function is a first-class function passed as an argument to another function — — with the expectation that the callback will be called when an asynchronous task gets completed.

An event handler is a form of a callback function. And in this section, we will learn how to enhance our code using callbacks.

Consider the code below:

In the code above, we modified the getPosts function to use a callback. Consequently, we can call the callback to handle the different results of the network request — if it is successful or if there is an error.

Also, whenever we reuse the getPosts function, we can pass a different callback to it. Thus we have made our code more reusable and more flexible.

Callback Hell

So we have seen that the callback pattern helps make our code more reusable and flexible. But when we need to make several network requests sequentially, the callback pattern can quickly become messy and hard to maintain.

But before we elaborate on this, let’s refactor our getPosts function as seen below:

In the code above, we made the resource URL dynamic by passing the resource argument as the first parameter to the getPosts function. Thus when we call the getPosts function, we can dynamically pass any URL we want.

Now, if we are to make the network requests we mentioned above, we will end up with deeply nested callbacks as seen below:

Things even can get worse as we nest more callbacks within callbacks. And this is referred to as callback hell. Callback hell is the drawback to the callback pattern.

To resolve callback hell, we use modern asynchronous JavaScript patterns such as promises or async-await.

Let’s learn about Promises in the next section.

Asynchronous programming With Promises

Promises are the foundations of modern asynchronous JavaScript, and promises are either resolved or rejected.

When an asynchronous function implements the Promise API, the function returns a promise object — often before the operation finishes. The promise object contains information about the current state of the operation and methods to handle its eventual success or failure.

To implement the promise API, we use the Promise constructor in an asynchronous function, as seen below:

In the example above, the Promise constructor takes a function — where the network request is made, as an argument. And this function takes two arguments: the resolve and the reject function.

The resolve function is called to resolve the promise if the request is successful, and the reject function is called if the request fails.

Now, when we call the asyncFunc function, it returns a promise object. So to work with this function, we call the then method — to work the returned data if the promise resolves and the catch method to handle the error if the promise is rejected.

Consider the code below:

With this knowledge let’s refactor our getPosts function to use the promise API.

Consider the code below:

The code above implements the Promises API, and we see that instead of calling callbacks in the event handler, we called the resolve function if the request is successful and the reject function if the request fails.

Chaining Promises

We have already seen how we chain promises by calling the .then and .catch methods. Chaining promises is very useful, especially in cases that can result in callback hell — where we need to fetch data sequentially as mentioned in a previous section.

Chaining promises together enable us to perform asynchronous tasks one after another in a clean way. To elaborate on this, we will implement the callback hell example using Promise API.

Consider the code below:

Note, the catch method in the promises above catches any error no matter the number of nested requests. Also, chaining promises, as seen above, gives us a cleaner and more maintainable way to make multiple network requests sequentially.

The Native Fetch API

The Fetch API is a fairly modern API for making HTTP requests in JavaScript, but it has many enhancements over the XMLHttpRequest object. Also, the Fetch API implements the promise API under the hood, and its syntax requires much less code, so it is easier to use.

The Fetch API is simply a function that takes a resource — an endpoint as its argument and returns a promise. Consequently, we can call .then and .catch methods to handle the cases where the promise is resolved and rejected.

We can implement our example using the Fetch API as seen below:

Note, in the code above, response.json() returns a promise so we leverage promise chaining to handle it.

Also, in a Nodejs environment, you will need to install a package such as node-fetch to work with the Fetch API.

Asynchronous programming With Async Await

The async and await keywords are recently introduced to JavaScript. And they enable us to chain promises together in a clean and much more readable way.

While the Promise API has a lot of improvements over callbacks, it can still get messy as we chain multiple promises together.

But with async-await, we can separate all asynchronous code into an asynchronous function and use the await keyword inside to chain promises together in a more readable way.

We can make a function asynchronous by adding the async keyword in front of it. Then we can use the await keyword inside that function to chain promises.

Consider the code below:

In the code above, we refactored the getPosts function from using the Promise API to async-await. And we can see that this is cleaner and more readable.

Also, the await keyword stops JavaScript from assigning a value to the response and data variables until the promise is resolved.

The power of the await keyword is that we can chain multiple promises sequentially within the asynchronous function, and the code is still non-blocking. So this is a cleaner, more readable, and maintainable way to handle promises compared to using the .then method.

Error Handling

When we implement the Promise API, we handle errors by calling the .catch method. However, in the async-await pattern, there is no such method available. So to handle errors when using the async-await keyword, we implement the async-await inside a try…catch block as seen below:

So in the code above, JavaScript executes the code in the try block and invokes the getPosts function. And if the promise is resolved, the JSON data is logged to the console. But if the promise is rejected, the code in the catch blocks runs. When the code in the catch block runs, the catch function receives the thrown error object as an argument and handles the error.

Conclusion

In this article, we have learned about asynchronous JavaScript. And how the patterns have evolved historically from callbacks to Promises to async-await. Also, we learned about the native Fetch API which is the modern javascript API for making a network request.

After going through this article, I do hope that you understand how asynchronous JavaScript works under the hood — even when you use high-level APIs like the Fetch API or the async-await pattern.

So although we all like to apply new technologies, upgrading our code — to modern APIs should be complemented with proper testing. And even if we feel we’ve tested everything before release it’s always necessary to verify that our users have a great experience with our product.

A solution like SessionStack allows us to replay customer journeys as videos, showing how our customers actually experience our product. We can quickly determine whether our product is performing according to their expectations or not. In case we see that something is wrong, we can explore all of the technical details from the user’s browser such as the network, debug information, and everything about their environment so that we can easily understand the problem and resolve it. We can co-browse with users, segment them based on their behavior, analyze user journeys, and unlock new growth opportunities for our applications.

There is a free trial if you’d like to give SessionStack a try.

SessionStack replaying a session

Interested in more about JavaScript? Check out all “How JavaScript works” publications here.

--

--