Submitted by gwagner on
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:
- 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 thejson()
function on the previously retrievedresponse
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.