Asynchronous JavaScript – Introduction
Synchronous vs. asynchronous programming
All the code presented so far in this tutorial is set up to be executed statement by statement, generally in a top-down order. The JavaScript engine waits for a statement to finish executing before moving on to the next statement. This is necessary because a statement usually depends on the result of previously executed statements. Code that executes this way is called a synchronous program.
Executing code mostly involves reading and writing values from and to memory and performing operations on them by the CPU. But sometimes code includes instructions that involve interactions outside CPU and memory. For example, making HTTP requests over a network, accessing a user's camera or microphone or requesting data from the hard disk. These tasks take a lot longer than the tasks that only require memory access and the CPU. If these tasks would be handled synchronously, the program would stop for the time the long-running operation takes, making the program unresponsive for the user and in the meantime the CPU would sit idly by.
In addition, it is not always clear to the program when a task has finished. For instance, the next example creates an <img>
element and
assigns an image to the src
attribute. Then it logs the image's width and height to the console. When assigning the image (fast operation) has finished,
it starts logging to the console, but the image (long-running operation) has not been loaded into memory yet. The image's dimensions cannot be retrieved yet, hence the
log 0x0.
const img = document.createElement('img');
img.src = "images/Girl_with_a_Pearl_Earring-420px.jpg";
console.log(`Image size: ${img.width}x${img.height}`); // logs: "Image size: 0x0"
PS: The above example will log 420x560 if the image is in client's browser cache. If the image was opened or loaded before, it may still be stored in browser cache when running the above code. How long a cache object lives depends on the browser settings.
Accessing the image before it is loaded into memory can be prevented by using a load event, which is an asynchronous operation.
const img = document.createElement('img');
img.src = "images/Girl_with_a_Pearl_Earring-420px.jpg";
img.onload = () => {
console.log(`Image size: ${img.width}x${img.height}`); // logs: "Image size: 420x560"
}
With asynchronous programming, the program allows multiple tasks to execute simultaneously. In this case, a long-running operation starts and executes while the rest of the program continues to run and when the long-running operation eventually completes, the program gets notified and has access to the result.
In the above example the long-running operation (the "event", the requesting and loading of the image) starts with initializing the img
's src
attribute.
When the "event" (the asynchronous loading) has finished, it notifies the program, on which the event handler
(the arrow function) can use the event's result (the image) to retrieve the dimensions of the image.
Asynchronous callback
Event handlers as in the above example (.onload
) are often used in a
callback function
to create a more general load function.
function loadImg(src, callback) {
const img = document.createElement('img');
img.src = src;
img.onload = () => callback(img);
};
loadImg('images/Girl_with_a_Pearl_Earring-420px.jpg', function(img) {
document.getElementById('imgcontainer').append(img);
document.getElementById('showdim').textContent =
`Image size: ${img.width}x${img.height}`;
});
Pyramid of doom
Using callback-based code to perform some operation that breaks down into a series of asynchronous functions results in nested callbacks: calling callbacks inside callbacks. Such a tree of nested callbacks can be hard to understand and makes it hard to handle errors. It is sometimes called a "callback hell" or the "pyramid of doom".
function step1(init, callback) {
const result = init + 1;
callback(result);
}
function step2(init, callback) {
const result = init + 2;
callback(result);
}
function step3(init, callback) {
const result = init + 3;
callback(result);
}
function series() {
step1(0, (result1) => {
step2(result1, (result2) => {
step3(result2, (result3) => {
console.log(`result: ${result3}`);
});
});
});
}
series(); // logs: "result: 6" // 0 + 1 + 2 + 3
Example from: MDN: Introducing asynchronous JavaScript.
To avoid doomed code, modern JavaScript provides a way to program asynchronous code using the Promise
object,
which will be explained in the next chapter.