This chapter explains the foundations of asynchronous programming in JavaScript.
Normally JavaScript runs in a single process – in both web browsers and Node.js. Inside that single process, tasks are run, one at a time. A task is a piece of code – think function with zero parameters. Tasks are managed via a queue:
The event loop runs continuously inside the JavaScript process. During each loop iteration, it takes one task out of the queue (if the queue is empty, it waits until it isn’t) and executes it. After the task is finished, control goes back to the event loop, which then retrieves the next task from the queue and executes it. And so on.
Task sources add tasks to the queue. Some of those sources run concurrently to the JavaScript process. For example, one task source takes care of user interface events: if a user clicks somewhere and a click listener was registered, then an invocation of that listener is added to the task queue.
The following JavaScript code is an approximation of the event loop:
while (true) {
const task = taskQueue.dequeue();
task(); // run task
}
The event loop is depicted in figure 42.1.
Figure 42.1: Task sources add code to run to the task queue, which is emptied by the event loop.
Many of the user interface mechanisms of browsers also run in the JavaScript process (as tasks). Therefore, long-running JavaScript code can block the user interface. Let’s look at a web page that demonstrates that. There are two ways in which we can try out that page:
demos/async-js/blocking.html
The following HTML is the page’s user interface:
<a id="block" href="">Block</a>
<div id="statusMessage"></div>
<button>Click me!</button>
The idea is that we click “Block” and a long-running loop is executed via JavaScript. During that loop, we can’t click the button because the browser/JavaScript process is blocked.
A simplified version of the JavaScript code looks like this:
document.getElementById('block')
.addEventListener('click', doBlock); // (A)
function doBlock(event) {
// ···
displayStatus('Blocking...');
// ···
sleep(5000); // (B)
displayStatus('Done');
}
function sleep(milliseconds) {
const start = Date.now();
while ((Date.now() - start) < milliseconds);
}
function displayStatus(status) {
document.getElementById('statusMessage')
.textContent = status;
}
These are the key parts of the code:
doBlock()
whenever the HTML element is clicked whose ID is block
.
doBlock()
displays status information and then calls sleep()
to block the JavaScript process for 5000 milliseconds (line B).
sleep()
blocks the JavaScript process by looping until enough time has passed.
displayStatus()
displays status messages inside the <div>
whose ID is statusMessage
.
How can we prevent a long-running operation from blocking the browser?
The operation can deliver its result asynchronously: Some operations, such as downloading files, run outside the JavaScript process and concurrently with it. If we invoke such an operation, we provide it with a callback. Once the operation is done, it calls the callback with the result (by adding a task to the queue). This style of delivering a result is called asynchronous because the invoker isn’t blocked while it waits for the result: It can do other things and is notified when the result is ready. Normal function invocations deliver their results synchronously. Our own code can also deliver results asynchronously. We’ll learn more about asynchronous code soon.
The operation can be performed in a separate process: This can be done via so-called Web Workers. A Web Worker is a heavyweight process that runs concurrently to the main process. It has its own runtime environment (global variables, etc.). It is completely isolated; communication happens via message passing. See MDN web docs for more information.
The operation can take breaks and give pending tasks in the queue a chance to run – which unblocks the browser. The next subsection explains how that is done.
setTimeout()
The following global function executes its parameter callback
after a delay of ms
milliseconds (the type signature is simplified – setTimeout()
has more features):
function setTimeout(callback: () => void, ms: number): any
The function returns a handle (an ID) that can be used to clear the timeout (cancel the execution of the callback before it happens) via the following global function:
function clearTimeout(handle?: any): void
setTimeout()
is available on both browsers and Node.js. We can view setTimeout()
as scheduling a task for later execution:
console.log('First task starts');
setTimeout(
() => { // (A)
console.log('Second task starts');
},
0 // (B)
);
console.log('First task ends');
Within the first task, we are scheduling a new task (the callback starting in line A) to be run after a delay of zero milliseconds (line B).
Output:
First task starts
First task ends
Second task starts
There is another way of looking at what happened: The first task took a break and later continued with the second task.
In other words: The task took a break and gave other tasks a chance to run.
JavaScript makes a guarantee for tasks:
Each task is always finished (“run to completion”) before the next task is executed.
As a consequence, tasks don’t have to worry about their data being changed while they are working on it (concurrent modification). That simplifies programming in JavaScript.
We can observe run to completion in the previous example:
console.log('First task starts');
setTimeout(
() => {
console.log('Second task starts');
},
0
);
console.log('First task ends');
The first task ends before the next task starts.
In order to avoid blocking the main process while waiting for a long-running operation to finish, results are often delivered asynchronously in JavaScript. These are three popular patterns for doing so:
The first two patterns are explained in the next two subsections. Promises are explained in the next chapter.
Events as a pattern work as follows:
Multiple variations of this pattern exist in the world of JavaScript. We’ll look at three examples next.
IndexedDB is a database that is built into web browsers. This is an example of using it:
const openRequest = indexedDB.open('MyDatabase', 1); // (A)
openRequest.onsuccess = (event) => {
const db = event.target.result;
// ···
};
openRequest.onerror = (error) => {
console.error(error);
};
indexedDB
has an unusual way of invoking operations:
Each operation has an associated method for creating request objects. For example, in line A, the operation is “open”, the method is .open()
, and the request object is openRequest
.
The parameters for the operation are provided via the request object, not via parameters of the method. For example, the event listeners (functions) are stored in the properties .onsuccess
and .onerror
.
The invocation of the operation is added to the task queue via the method (in line A). That is, we configure the operation after its invocation has already been added to the queue. Only run-to-completion semantics saves us from race conditions here and ensures that the operation runs after the current code fragment is finished.
XMLHttpRequest
The XMLHttpRequest
API lets us make downloads from within a web browser. This is how we download the file http://example.com/textfile.txt
:
const xhr = new XMLHttpRequest(); // (A)
xhr.open('GET', 'http://example.com/textfile.txt'); // (B)
xhr.onload = () => { // (C)
if (xhr.status == 200) {
processData(xhr.responseText);
} else {
assert.fail(new Error(xhr.statusText));
}
};
xhr.onerror = () => { // (D)
assert.fail(new Error('Network error'));
};
xhr.send(); // (E)
function processData(str) {
assert.equal(str, 'Content of textfile.txt\n');
}
With this API, we first create a request object (line A), then configure it, then activate it (line E). The configuration consists of:
GET
, POST
, PUT
, etc.
xhr
. (I’m not a fan of this kind of mixing of input and output data.)
We have already seen DOM events in action in “The user interface of the browser can be blocked” (§42.2.1). The following code also handles click
events:
const element = document.getElementById('my-link'); // (A)
element.addEventListener('click', clickListener); // (B)
function clickListener(event) {
event.preventDefault(); // (C)
console.log(event.shiftKey); // (D)
}
We first ask the browser to retrieve the HTML element whose ID is 'my-link'
(line A). Then we add a listener for all click
events (line B). In the listener, we first tell the browser not to perform its default action (line C) – going to the target of the link. Then we log to the console if the shift key is currently pressed (line D).
Callbacks are another pattern for handling asynchronous results. They are only used for one-off results and have the advantage of being less verbose than events.
As an example, consider a function readFile()
that reads a text file and returns its contents asynchronously. This is how we call readFile()
if it uses Node.js-style callbacks:
readFile('some-file.txt', {encoding: 'utf-8'},
(error, data) => {
if (error) {
assert.fail(error);
return;
}
assert.equal(data, 'The content of some-file.txt');
});
There is a single callback that handles both success and failure. If the first parameter is not null
then an error happened. Otherwise, the result can be found in the second parameter.
Exercises: Callback-based code
The following exercises use tests for asynchronous code, which are different from tests for synchronous code. See “Asynchronous tests in Mocha” (§12.2.2) for more information.
exercises/async-js/read_file_cb_exrc.mjs
.map()
: exercises/async-js/map_cb_test.mjs
In many situations, on either browsers or Node.js, we have no choice: We must use asynchronous code. In this chapter, we have seen several patterns that such code can use. All of them have two disadvantages:
The first disadvantage becomes less severe with Promises (covered in the next chapter) and mostly disappears with async functions (covered in the chapter after next).
Alas, the infectiousness of async code does not go away. But it is mitigated by the fact that switching between sync and async is easy with async functions.