Asynchronous Programming Background
JavaScript engines are built on the concept of a single-threaded event loop. Single-threaded means that only one piece of code is ever executed at a time. Contrast this with languages like Java or C++, where threads can allow multiple different pieces of code to execute at the same time. Maintaining and protecting state when multiple pieces of code can access and change that state is a difficult problem and a frequent source of bugs in thread-based software.
JavaScript engines can only execute one piece of code at a time, so they need to keep track of code that is meant to run. That code is kept in a job queue. Whenever a piece of code is ready to be executed, it is added to the job queue. When the JavaScript engine is finished executing code, the event loop executes the next job in the queue. The event loop is a process inside the JavaScript engine that monitors code execution and manages the job queue. Keep in mind that as a queue, job execution runs from the first job in the queue to the last.
The Event Model
When a user clicks a button or presses a key on the keyboard, an event like onclick
is triggered. That event might respond to the interaction by adding a new job to the back of the job queue. This is JavaScript’s most basic form of asynchronous programming. The event handler code doesn’t execute until the event fires, and when it does execute, it has the appropriate context. For example:
let button = document.getElementById("my-btn");
button.onclick = function(event) {
console.log("Clicked");
};
In this code, console.log("Clicked")
will not be executed until button
is clicked. When button
is clicked, the function assigned to onclick
is added to the back of the job queue and will be executed when all other jobs ahead of it are complete.
Events work well for simple interactions, but chaining multiple separate asynchronous calls together is more complicated because you must keep track of the event target (button
in the previous example) for each event. Additionally, you need to ensure all appropriate event handlers are added before the first time an event occurs. For instance, if button
were clicked before onclick
is assigned, nothing would happen. So while events are useful for responding to user interactions and similar infrequent functionality, they aren’t very flexible for more complex needs.
The Callback Pattern
When Node.js was created, it advanced the asynchronous programming model by popularizing the callback pattern of programming. The callback pattern is similar to the event model because the asynchronous code doesn’t execute until a later point in time. It’s different because the function to call is passed in as an argument, as shown here:
readFile("example.txt", function(err, contents) {
if (err) {
throw err;
}
console.log(contents);
});
console.log("Hi!");
This example uses the traditional Node.js error-first callback style. The readFile()
function is intended to read from a file on disk (specified as the first argument) and then execute the callback (the second argument) when complete. If there’s an error, the err
argument of the callback is an error object; otherwise, the contents
argument contains the file contents as a string.
Using the callback pattern, readFile()
begins executing immediately and pauses when it starts reading from the disk. That means console.log("Hi!")
is output immediately after readFile()
is called, before console.log(contents)
prints anything. When readFile()
finishes, it adds a new job to the end of the job queue with the callback function and its arguments. That job is then executed upon completion of all other jobs ahead of it.
The callback pattern is more flexible than events because chaining multiple calls together is easier with callbacks. For example:
readFile("example.txt", function(err, contents) {
if (err) {
throw err;
}
writeFile("example.txt", function(err) {
if (err) {
throw err;
}
console.log("File was written!");
});
});
In this code, a successful call to readFile()
results in another asynchronous call, this time to the writeFile()
function. Note that the same basic pattern of checking err
is present in both functions. When readFile()
is complete, it adds a job to the job queue that results in writeFile()
being called (assuming no errors). Then, writeFile()
adds a job to the job queue when it finishes.
This pattern works fairly well, but you can quickly find yourself in callback hell. Callback hell occurs when you nest too many callbacks, like this:
method1(function(err, result) {
if (err) {
throw err;
}
method2(function(err, result) {
if (err) {
throw err;
}
method3(function(err, result) {
if (err) {
throw err;
}
method4(function(err, result) {
if (err) {
throw err;
}
method5(result);
});
});
});
});
Nesting multiple method calls as this example does creates a tangled web of code that is hard to understand and debug. Callbacks also present problems when you want to implement more complex functionality. What if you want two asynchronous operations to run in parallel and notify you when they’re both complete? What if you’d like to start two asynchronous operations at a time but only take the result of the first one to complete?
In these cases, you’d need to track multiple callbacks and cleanup operations, and promises greatly improve such situations.