Asynchronous Programming in JavaScript

gwagner's picture

In programming, we often have the situation that, when calling a possibly time-consuming input/output (I/O) operation (or any long-running operation, e.g., for performing a complex computation), the program execution has to wait for its result being returned before it can go on. Calling such an operation and waiting for its result, while the main program's further execution (and its entire thread) is blocked, represents a synchronous operation call. The implied waiting/blocking poses a problem for a JS program that is being executed in a browser thread since during the waiting time the user interface (in a browser tab) would be frozen, which is not acceptable from a usability point of view and therefore not accepted by browsers.

Consequently, in JavaScript, it is not possible to call an I/O operation, e.g., for fetching data from a webpage (with the built-in XMLHttpRequest or fetch API) or for accessing a remote database (via HTTP request-response messaging) synchronously. These types of operations have to be performed in an asynchronous (non-blocking) manner, instead.

Asynchronous programming concepts in JavaScript have undergone an evolution from callbacks to promises to generators (coroutines) and, most recently, to asynchronous procedure calls with await procedure invocation expressions and asynchronous procedure definitions with async. Each evolution step has made asynchronous programming a little bit easier for those who have taken the effort to get familiar with it.

Due to this evolution, operations of older JS input/output APIs available in the form of built-in objects, like XMLHttpRequest for HTTP messaging or indexedDB for object database management, work with callbacks, while newer APIs, like fetch for HTTP messaging, work with promises and can also be invoked with await.

Callbacks

A simple asynchronous programming approach consists of defining a procedure that is to be executed as soon as the asynchronous operation completes. This allows to continue the program execution after the invocation of the asynchronous operation, however, without assuming that the operation result is available. But how does the execution environment know, which procedure to call after completing the asynchronous operation?

In JS, we can pass a JS function as an argument in the invocation of the asynchronous operation. A callback is such a JS function.

Consider the following example. An external JS file can be dynamically loaded (in the context of an already loaded webpage with associated JS code) by (1) programmatically creating an HTML script element DOM object with the file's URL as the value of the script's src attribute, and (2) inserting the newly created script element after the last child node of the document's head element:

function loadJsFile( fileURL) {
  const scriptEl = document.createElement("script");
  script.src = fileURL;
  document.head.append( scriptEl);
}

When the new script element is inserted into the document's DOM, e.g., with the help of the asynchronous DOM operation append (at the end of the loadJsFile procedure), the browser will load the JS file and then parse and execute it, which will take some time. Let's assume that we have a JS code file containing the definition of a function addTwoNumbers that does what its name says and we first load the file and then invoke the function in the following way:

loadJsFile("addTwoNumbers.js");
console.log( addTwoNumbers( 1, 2));

This wouldn't work. We would get an error message instead of the sum of 1 and 2, since the intended result of the first statement, the availability of the addTwoNumbers function, is not (yet) obtained when the second statement is executed.

We can fix this by adding a callback procedure as a second parameter to the loadJsFile procedure and assign it as an event handler of the JS file load event :

function loadJsFile( fileURL, callback) {
  const scriptEl = document.createElement("script");
  script.src = fileURL;
  script.onload = callback;
  document.head.append( scriptEl);
}

Now when calling loadJsFile we can provide the code to be executed after loading the "addTwoNumbers.js" file in an anonymous callback function:

loadJsFile("addTwoNumbers.js", function () {
  console.log( addTwoNumbers( 1, 2));  // results in 3
]);

Since the loading of the JS file can fail, we should better add some error handling for this case by defining an event handler for the error event. We can handle possible errors within the callback procedure by calling it with an error argument:

function loadJsFile( fileURL, callback) {
  const scriptEl = document.createElement("script");
  script.src = fileURL;
  script.onload = callback;
  script.onerror = function () {
      callback( new Error(`Script load error for ${fileURL}`));
  };
  document.head.append( scriptEl);
}

Now we call loadJsFile with an anonymous callback function having an error parameter:

loadJsFile("addTwoNumbers.js", function (error) {
  if (!error) console.log( addTwoNumbers(1,2));  // results in 3
  else console.log( error);
]);

Callbacks work well as an asynchronous programming approach in simple cases. But when it is necessary to perform several asynchronous operations in a sequence, one quickly ends up in a "callback hell", a term that refers to the resulting deeply nested code structures that are hard to read and maintain.

Promises

A promise (also called future in some programming languages, like in Python) is a special object that provides the deferred result of an asynchronous operation to the code that waits for this result. A promise object is initially in the state pending. If the asynchronous operation succeeds (in the case when the resolve function is called with an argument providing the result value), the promise state is changed from pending to fulfilled. If it fails (in the case when the reject function is called with an argument providing the error), the promise state is changed from pending to rejected.

An example of a built-in asynchronous operation that returns a promise is import for dynamically loading JS code files (and ES6 modules). We can use it instead of the user-defined loadJsFile procedure discussed in the previous section for loading the addTwoNumbers.js file and subsequently executing code that uses the addTwoNumbers function (or reporting an error if the loading failed):

import("addTwoNumbers.js")
.then( function () {
  console.log( addTwoNumbers( 1, 2));
})
.catch( function (error) {
  console.log( error);
});

This example code shows that on the promise object returned by import we can call the predefined functions then and catch:

then
for continuing the execution only when the import operation is completed with a fulfilled promise, and
catch
for processing the error result of a rejected promise.

The general approach of asynchronous programming with promises requires each asynchronous operation to return a promise object that typically provides either a result value, when the promise is fulfilled, or an error value, when the promise is rejected. For user-defined asynchronous procedures, this means that they have to create a promise as their return value, as shown in the promise-valued loadJsFile function presented below.

A promise object can be created with the help of the Promise constructor by providing an anonymous function expression as the argument of the Promise constructor invocation (with two parameters resolve and reject representing JS functions). We do this in the following example of a promise-valued loadJsFile function, which is a variant of the previously discussed callback-based loadJsFile procedure:

function loadJsFile( fileURL) {
  return new Promise( function (resolve, reject) {
    const scriptEl = document.createElement("script");
    scriptEl.src = fileURL;
    scriptEl.onload = resolve;
    scriptEl.onerror = function () {
        reject( new Error(`Script load error for ${fileURL}`));
    };
    document.head.append( scriptEl);
  });
}

This new version of the asynchronous loadJsFile operation is used in the following way:

loadJsFile("addTwoNumbers.js")
.then( function () {
  console.log( addTwoNumbers( 1, 2));
})
.catch( function (error) {
  console.log( error);
});

We can see that even the syntax of a simple promise-valued function call with then and catch is more clear than the syntax of a callback-based asynchronous procedure call. This advantage is even more significant when it comes to chaining asynchronous procedure calls, as in the following example where we first sequentially load three JS files and then invoke their functions:

loadJsFile("addTwoNumbers.js")
.then( function () {
  return loadJsFile("multiplyBy3.js");})
.then( function () {
  return loadJsFile("decrementBy2.js");})
.then( function () {
  console.log( decrementBy2( multiplyBy3( addTwoNumbers(1,2))));})
.catch( function (error) {
  console.log( error);
});

Notice that for executing a sequence of asynchronous operations with then, we need to make sure that each then-function returns a promise.

As an alternative to the sequential execution of asynchronous operations, we may also execute them in parallel with Promise.all:

Promise.all([ loadJsFile("addTwoNumbers.js"),
  loadJsFile("multiplyBy3.js"),
  loadJsFile("decrementBy2.js")
])
.then( function () {
  console.log( decrementBy2( multiplyBy3( addTwoNumbers(1,2))));
})
.catch( function (error) {console.log( error);});

Unlike loadJsFile, which simply completes with a side effect (the loading of JS code), but without a result value being returned, a typical asynchronous operation returns a promise object that provides either a result value, when the promise is fulfilled, or an error value, when the promise is rejected.

Let's consider another example, where we have asynchronous operations with result values. The JS built-in fetch operation allows retrieving the contents of a remote resource file via sending HTTP request messages in two steps:

  1. In the first step, it returns a promise that resolves with a response object as its result value containing the HTTP header information retrieved.
  2. Then, invoking the text() or the json() function on the previously retrieved response object returns a promise that resolves to the HTTP response message's body (in the form of a string or a JSON object) when it is retrieved from the remote server.

In such a case, when we chain two or more asynchronous operation calls with result values, each successor call can be expressed as a transformation from the previous result to a new result using arrow functions as shown in line 2 of the following example:

fetch("user1.json")
.then( response => response.json())
.then( function (user1) {alert( user1.name);})
.catch( function (error) {console.log( error);});

Notice that the text file "user1.json" is assumed to contain a JSON object describing a particular user with a name field. This JSON object is retrieved with the arrow function expression in line 2.

Calling asynchronous operations with await

When a program with a statement containing an asynchronous procedure call (with await) is executed, the program will run up to that statement, call the procedure, and suspend execution until the asynchronous procedure execution completes, which means that if it returns a Promise, it is settled. That suspension of execution means that control is returned to the event loop, such that other asynchronous procedures also get a chance to run. If the Promise of the asynchronous procedure execution is fulfilled, the execution of the program is resumed and the value of the await expression is that of the fulfilled Promise. If it is rejected, the await expression throws the value of the rejected Promise (its error).

When we use await for invoking a Promise-valued JS function, we typically do not use Promise chaining with .then, because await handles the waiting for us. And we can use a regular try-catch block instead of a Promise chaining .catch clause, as shown in the following example code:

try {
  await loadJsFile("addTwoNumbers.js");
  console.log( addTwoNumbers(2,3));
} catch (error) {
  console.log( error);
}

Notice that this is the code of an ES6 module. In a normal JS file, await can only be used within async functions.

When we call several asynchronous procedures in succession with await, the code reads in a natural way, similar to the code for calling synchronous procedures:

try {
  await loadJsFile("addTwoNumbers.js");
  await loadJsFile("multiplyBy3.js");
  await loadJsFile("decrementBy2.js");
  console.log( decrementBy2( multiplyBy3( addTwoNumbers(2,3))));
} catch (error) {
  console.log( error);
}

In an async function, we can invoke Promise-valued functions in await expressions. Since an async function returns a Promise, it can itself be invoked with await.

async function load3JsFiles() {
  await loadJsFile("addTwoNumbers.js");
  await loadJsFile("multiplyBy3.js");
  await loadJsFile("decrementBy2.js");
}
try {
  await load3JsFiles();
  console.log( decrementBy2( multiplyBy3( addTwoNumbers(2,3))));
} catch (error) {
  console.log( error);
}

In the more typical case of asynchronous operation calls with result values, we obtain code like the following await-based version of the above promise-based example of using fetch:

try {
  const response = await fetch("user1.json");
  const user1 = await response.json();
  alert( user1.name);
} catch (error) {
  console.log( error);
}

For more about asynchronous programming techniques, see Promises, async/await and Demystifying Async Programming in Javascript.

Category: