The evolution of asynchronous programming in JavaScript (from callbacks via promises to asynchronous procedure calls with await procedure invocation expressions and asynchronous procedure definitions with async) has made asynchronous programming easier.
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 lot 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));
]);
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));
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:
- In the first step, it returns a promise that resolves with a
response
object as its result value containing the HTTP header information retrieved. - 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.