How To Read And Use Asynchronous Execution In Javascript

Understanding asynchronous execution can be a tricky affair in all programming languages. Dealing with incorrect handling of asynchronous code is so subtle and insidious that countless bugs, design flaws, and huge refactors have been madeā€”no doubt to underestimating the difficulty that asynchronous code execution brings to any technical design. Small mistakes will compound and be paid back in months of bug fixes, bug nests, and a full feature progression halt. Let’s learn more about asynchronous code execution and design our programs and features around it.

The history of Javascript and asynchronous code execution is messy at best and a train wreck and worst. Let’s dive in and understand how Javascript has adapted to bolt in capability around the beast of asynchronous functions.

How does asynchronous execution differ from single to multi-threaded applications?

I won’t get into too much detail on the topic of threads and processes here. In the quick version, a single process uses threads that execute “work.” To put it shortly, a process representing an application with multiple threads explicitly running your code is “multi-threading,” and if it has just one, it is a “single-thread.”

The trickiness around work in multi-threading applications is when the work is done, how the result will persist, and how the application notifies dependencies. In essence, two threads running two separate code paths cannot easily communicate and essentially achieve work as a black box. Your code must reconcile potential timing and resource contention issues between multiple threads.

Single-threaded applications do simplify the need to watch out for the dreaded multi-threaded anti-patterns. From deadlock, priority inversion, and race conditions, these traditional asynchronous multithreading bugs suddenly become nonexistent in single-threaded applications. This has the perk of being substantially easier to develop with, but it does not give you the total performance bandwidth of multi-threaded processors in terms of code being run. Languages such as Python and Javascript have single-threads.

In a single thread, logic executes sequentially always. It is predictable in its behavior and only complex with the system around it. There are no surprises or gotchas. Easy right? The devil is in the details, and with Javascript, the devil exists with understanding how it deals with asynchronous code even though it is still a single thread.

Why a single-threaded program still needs asynchronous execution

Javascript has traditionally been used as a front-end language on browsers (although that is not always the case). It is worth noting that while Javascript is still running a single-thread, the browser is a complex system and is made of several processes. Including a rendering process (that encapsulates the DOM, rasterization, and compositing of a single app). Then there is the main thread that executes the app’s code single-threaded.

One major performance bottleneck of running complex algorithms or time-consuming functions on the web app’s main thread is that this could interfere with rendering performance and cause stutters in rendering, causing frame rate loss. Thus, transfer continuous work to run on worker threads or ensure asynchronous work does not synchronously lock the thread with work. Imagine if our Javascript code was not asynchronous and freezes code execution on a network call that took 3-5 seconds! The UI and app would freeze for that whole period of time!

Javascript and asynchronous execution

Since Javascript has a single-thread, it interacts with the rasterization and compositing threads through the built-in browser APIs that are exposed on the global object (often “window”) as the “window.document”. The browser will handle most of the multi-threading issues for dealing with the work between the main thread (your code), rasterization, and compositing. Simple!

It gets a bit more complex. Browsers have a feature to support multi-threading in browsers by allowing an application to launch “workers.” These worker threads run their own single thread Javascript loop. There are a variety of types of workers, such as web, service, and shared workers. These do introduce the multi-threading problem to Javascript but are innately a browser feature and not one of Javascript itself. Each of these threads runs a Javascript thread and can communicate through several browser APIs in real-time. Workers are a whole topic of their own and worth diving into deeper in their own article.

How is it even asynchronous if it has a single-thread, you ask? The short of it is that Javascript alone cannot perform asynchronous threading work (in the traditional sense). The framework around it that deals with network stack calls, OS calls, file system calls, etc., will handle the asynchronous work. They are given a “hook” to notify Javascript when the work is complete. During Javascript’s single-threaded runtime loop, it can then call synchronously to the callbacks for the work. How to handle these callbacks succinctly in a robust manner is something Javascript has struggled with.

In fact, it was not until ES6 that introduced asynchronous code execution feature support with promises. ES7 eventually brought the addition of async/await. Promises and async/await are only sugar syntax to patch up the lack of a language-specific resolution to needing to handle asynchronous remote work when it was complete.

Callbacks

If Javascript has a single-thread, why do we even need to handle asynchronous problems? Well, for one, your single-threaded application might need to communicate out of its’ single thread, in which now you have an asynchronous problem. All network calls are an example. Further, now all worker communication is another. The functional Javascript solution originally was to use callbacks. A callback is essentially a function given to another function to execute after work is complete. They come in especially handy in asynchronous code when work ends in an indeterminate amount of time, such as requesting data from the data server.

The callback would receive the data, transform it and then return it or do something with it. However, there would be a need for callbacks to wrap callbacks and add layers to the callback stack, any of which can be passed around in the code, tough to locate, tough to debug, and have many possible side effects creating strong coupling of concerns along their callback paths. This was the infamous Javascript “callback hell.”

The problem was the callback can pass through layers of functions, and at any point, other callbacks further wrap the original callback. A bug fix or change to one callback can have much unknown regression potential to any callbacks consuming it downstream. Worst these functions were arguments to functions; those argument names can change from function to function, making them very difficult to find.

Example

// Succinct example of a callback hell (it can get a lot worse)
function render () {
  const renderCallback = function (text) {
    const textNode = document.createTextNode(text);
    document.body.appendChild(textNode);
  };

  getData(renderCallback);
}

function getData (renderCb) {
  const xhr = new XMLHttpRequest();
  const failCb = function () {
    renderCb("ERROR");
  };
  const successCb = function () {
    if (xhr.status !== 200) {
      setTimeout(function () {
        renderCb(xhr.statusText);
      });
    } else {
      failCb();
    }
  };

  xhr.onload = successCb;
  xhr.onerror = failCb;
  xhr.open('GET', '/some/text/');
  xhr.send();
}

Promises

Something needs to be done! Developers were flipping their desks in anger and outrage at having to use Javascript for front-end development. ES6 to the rescue? ES6 introduced a solution to the callback hell. Still, its slow rollout leads to many third-party libraries implementing the standard in their own unique ways. They tried to be “close” to the real one, eventually implemented in Javascript but failed fairly miserably. Enter the “promise hell.”

The idea of the promise is to need no longer to pass in a callback to the function providing asynchronous work. Instead, promises enable the functions doing asynchronous work to return a “promise” that will allow the caller to give a callback for when the job completes and another callback for if it fails.

const promise = new Promise();
promise.then(() => {
  // call back on success
});
promise.catch(error => {
  // callback on fail
});

Promise Chaining

As mentioned, by the time Javascript Promises hit the market in ES6, there were already several implementations of Promises in use. This made it very difficult in some codebases to reconcile which kind of promises were in use and where. Also, writing code around promises can go tragically wrong. It is complex to read and debug if written poorly.

There is a decent way to make write promises and promote readability, though. Since functions that will return a promise can chain off another promise’s success, the promises can be “chained” together for a much more readable format.

// Avoid this nesting of promises that becomes unruly
const promise = new Promise();
return promise.then(() => {
  return getData().then((data) => {
    return processData(data).then(processedData => {
      return processedData;
    });
  });
});

// Favor promise chaining for its more readable format
const promise = new Promise();
return promise
  .then(() => getData())
  .then((data) => processData(data))
  .then((processedData) => {
    processedData.someTransform = 5;
    return processedData;
  });

Promise exceptions

Another feature of promises is that errors no longer crash your application and now handle differently. If a promise fails with no catch(), it will be reported as an unhandled global exception. Otherwise, it fails silently, which can have a lot of potential for allowing regressions to be missed.

Promises at runtime

You might be wondering that since Javascript has a single-thread, how does it deal with the code that needs to run after “work” is completed elsewhere and comes back to our Javascript thread? In the runtime loop of Javascript, it will check the promises that are “pending” for work to be complete. Once they are either resolved or rejected, it will call back the appropriate then() or catch() that have been registered on the promise. It is important to note, you have no control over when this could happen, and Promises do not have timeouts built-in.

Example

// Succinct example of a promises replacing callbacks from prior example in Callbacks
function render () {
  return getData().then((text) => {
    const textNode = document.createTextNode(text);
    document.body.appendChild(textNode);
  });
}

function getData () {
  return fetch('/some/text/')
  .then(response => {
    if (response.ok) {
      return response.statusText;
    } else {
      return "ERROR";
    }
  });
}

Async/Await pattern

Promises were not the end-all solution, and ES7 came out with a knight in shining armor to save the day… again. The async/await pattern is a “wrapper” around the promise pattern from ES6 with some subtle differences. The async/await pattern was growing popular in other programming languages, and adoption took Javascript by storm. (Aside from one highly used but no longer supported browser). Yes, I am looking at you, Internet Explorer.

The async/await pattern really cleaned up a lot of the issues around promises. First, there is no easy way for you to see that any given function produces a promise as a return. Sometimes bugs can weed into your code simply because you miss that you are dealing with an asynchronous function. For a function to be “async,” it needs to be marked with the async keyword.

Second, the ability to add the “await” keyword before a function that returned a promise. Now all the then() registration and callbacks will be handled for you and inline the code. This not only made the code more succinct but also made it more readable without the chaining.

// This
function () {
const promise = new Promise();
  return promise
    .then(() => getData())
    .then((data) => processData(data))
    .then((processedData) => {
      processedData.someTransform = 5;
      return processedData;
    });
}

// Became this
async function () {
  const data = await getData();
  const processedData = await processData(data);
  processedData.someTransform = 5;
  return processedData;
}

Async exceptions

You might notice that there is no more ability to catch() with the async/await pattern. This is, in fact, a very subtle difference but an important one! Async/await will immediately throw a new Error() if the catch function is called. Thus the global exception handler is never called. This means you must handle your errors in place with a try/catch block for anywhere that can throw.

// This
function () {
  const promise = new Promise();
  return promise
    .then(() => getData())
    .catch((error) => console.error(error));
}

// Became this
async function () {
  try {
    const data = await getData();
  } catch (error) {
    console.error(error)
  }
}

This helps clean up and keep error handling concerns a bit closer to the root of what can throw. Ultimately, async/await builds off the promise pattern and is a feature superset so that you can use both designs interchangeably.

Example

// Succinct example of async/await replacing promises of the prior example used in Promises
async function render () {
  const text = await getData();
  const textNode = document.createTextNode(text);
  document.body.appendChild(textNode);
}

async function getData () {
  const response = await fetch('/some/text/');
  if (response.ok) {
    return response.statusText;
  } else {
    return "ERROR";
  }
}

Asynchronous versus synchronous execution

Lastly, you will see from the above examples how running simple sequential synchronous code can quickly become complex when asynchronous execution gets introduced. Sequential code is predictable and easy to follow, line after line. Asynchronous code is somewhat infectious as all callers in the stack to the dependency asynchronous call now will need to deal with not being sequential. All of the code dependent on the asynchronous code had to somehow “chain” the callbacks, promises, or async/await pattern throughout.

This can result in messy refactors and challenging to use APIs when needing to retroactively “switch” from synchronous API to an asynchronous API. Thus, as a rule of thumb, when designing your code, evaluate where and what dependencies will be made around these asynchronous network calls, file operations, OS calls, etc. Identify those dependencies early. Ensuring your functions can return promises or use async/await from the caller’s call will save headaches when identified early.