Synchronous vs. Asynchronous Code
By default, code in JavaScript executes synchronously, meaning one line after the next. This means that a line of code can’t execute until all of the previous lines of code in its lexical scope have finished executing.
This isn’t always desirable. Suppose, for example, our line of code is waiting for a server to send some data. It would be nice if we could take care of other things while we were waiting.
This is what asynchronous (async for short) code does. Instead of requiring the code following it to wait until it’s done executing, as synchronous code does, async code starts, returns control back to its calling context, and then notifies that calling context when it’s finished executing.
An analogy for synchronous execution might be a phone call. Suppose I want to know whether a package has made it to the post office. I call the post office. The clerk puts me on hold, goes to check whether the package is there, and comes back to me with an answer. I’m stuck on the phone until the clerk is done checking. That’s synchronous behavior.
On the other hand, I might also call the post office looking for my package and the clerk might say “I’ll need a few minutes to look that up for you. Perhaps I can call you back when I have an answer?” I say great, here’s my phone number. I hang up and go work on something else. Later, my phone rings, I answer it, and the clerk tells me whether my package has arrived. That’s asynchronous behavior.
Basic Asynchronous Behavior: Callback Functions and setTimeout
The usual way to implement asynchronous behavior is with a callback function. This is a function that the caller passes as an argument to the async function. When the async function completes, it invokes the callback function.
A bit of code will demonstrate how this works in JavaScript. For asynchronous behavior, we’ll use setTimeout, which executes its callback function asynchronously after a specified minimum amount of time has elapsed. (If that specified amount is zero, it still executes asynchronously.) We’ll also use confirm, which executes synchronously. In other words, confirm
stops code from executing until Cancel
or Ok
is clicked.
We’ll start by setting up a couple of buttons in HTML:
1 2 3 4 5 6 7 8 9 10 11 |
<!doctype html> <html> <body> <div> <button id="sync">Sync</button> <button id="async">Async</button> </div> </body> </html> |
And now, we’ll set up handlers for the two buttons:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
document.addEventListener('DOMContentLoaded', e => { let label = document.querySelector('label'); document.querySelector('#sync').addEventListener('click', e => { console.log('This code comes before confirm is called'); console.log('Confirm value: ' + confirm('Yes or no?')); console.log('This code comes after confirm is called'); }); document.querySelector('#async').addEventListener('click', e => { console.log('This code comes before confirm is called'); setTimeout(() => console.log('Confirm value: ' + confirm('Yes or no?')), 0); console.log('This code comes after confirm is called'); }); }); |
Clicking the Sync
button, and then OK
in the confirm dialog, will log this:
1 2 3 4 5 |
This code comes before confirm is called Confirm value: true This code comes after confirm is called |
Clicking the Async
button, and then OK
in the confirm dialog, will log this:
1 2 3 4 5 |
This code comes before confirm is called This code comes after confirm is called Confirm value: true |
The confirm
box represents a long-running item of code. It is synchronous, and so it blocks all other activity until the user selects Ok
or Cancel
. Running confirm
from inside setTimeout
‘s callback function removes the block to other synchronous code that follows the confirm
lexically.
Internally, the call stack is responsible for executing function calls. The way that asynchronous calls work is that calls to their callbacks aren’t pushed directly on to the call stack. Instead, they are pushed into a message queue. The message queue monitors the call stack, waits until it is empty, and when it is, transfers calls from the queue to the stack so they can be run. This is why synchronous code runs before asynchronous code, and this is why setTimeout
‘s callback executes last in the above example, even though its delay
value is set to 0
.
Using the XMLHttpRequest
API to work with HTTP methods
One of the more common uses for async code is when fetching data from a server. The XMLHttpRequest
API is one way to do this asynchronously. It uses events to accomplish this; for example, to fetch data it sends an HTTP GET
method to a server, and it exposes a load
event that fires when the data becomes available.
The XMLHttpRequest
API can handle other HTTP methods such as POST
, PUT
and DELETE
as well.
This code uses an XMLHttpRequest
object to perform a GET
request:
1 2 3 4 5 6 7 8 9 |
const xhr = new XMLHttpRequest; xhr.open('GET', 'book-example.herokuapp.com/v1/products'); xhr.responseType = 'json'; xhr.send(); xhr.addEventListener('load', e => { console.log(xhr.response); }); |
The responseType
property formats the response as JSON. The send
method actually sends the request. When the server sends a response, the load
event fires and the handler logs the response to the console.
The Promise
object
The Promise
API decouples asynchronous behavior from implementation-specific details such as asynchronous interaction with server data (with the XMLHttpRequest
API) or asynchronous reading of local files (with the FileReader
API).
A Promise
object represents a future result of an asynchronous operation. It has three possible states: pending
, fulfilled
and rejected
. Once the operation has either completed or failed, the pending
state changes to either fulfilled
or rejected
. Once that change is made, no further state changes are possible.
A Promise
object exposes a then
method, which has two parameters: onFulfilled
and onRejected
. Both of these accept a function that gets called when a Promise
is either fulfilled or rejected: onFulfilled
gets called when a Promise
is fulfullled, and onRejected
when it’s rejected.
A rejected Promise
has a reason
argument that cannot change. The Promise
object provides a catch
method, which is used to access this argument’s value.
A fulfilled Promise
has a value
argument that cannot change. The Promise
object provides a then
method, which is used to access this argument’s value.
The then
method also returns a new Promise
object, which means that Promise
objects can be chained together with successive then
calls.
Using the Fetch
API to work with HTTP methods
The Fetch
API works with the Promise
API to interact with server data.
This bit of code uses the Fetch
API to perform the same GET
request as the above XMLHttpRequest
example does:
1 2 3 4 5 |
fetch('book-example.herokuapp.com/v1/products') .then(response => response.json()) .then(data => console.log(data)); |
Here’s how it works. The fetch
method accepts a URL argument. If nothing else is specified, an HTTP GET
request is sent to the provided URL. Once the request is sent, fetch
returns a Promise
object. This object is fulfilled when the URL returns a response, resolving to a Response
object representing the response to the GET
request. This object becomes the argument passed to the callback of the first then
method. This callback converts the Response
object’s data to JSON data (by calling Response.json()
). This JSON data becomes the argument passed to the callback of the second then
method.
The fetch
method can accept an additional options
parameter, which is an object containing name/value pairs of various options. We have to specify some of these additional options if we want to do anything other than a simple GET
request. For example, to post data, we have to do something like this:
1 2 3 4 5 6 7 8 9 10 11 |
fetch('api/staff_members', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ name: 'Robert Rodes 2', email: 'robertrodes@robertrodes.com' }), }) .then(response => console.log('New staff member successfully added')) |
To use the POST
method, it has to be specified in the method
option. The body
option contains the data to be posted. The headers
option is an object containing name/value pairs of headers. If the server is expecting data in JSON format (which it may not although JSON is the most often-used format), we have to specify that in the Content-Type
header.
async
and await
The async
/await
syntax is syntactic sugar for the Promise
object. This syntax has a few rules:
1. To create an asynchronous function, a function declaration or expression is preceded by the async
keyword.
2. The await
keyword can only be used inside a function that is declared with the async
keyword.
3. The await
keyword awaits the resolution of a Promise
object, much like the then
method.
4. The catch
and finally
methods of the Promise
object are replaced by the standard try/catch/finally
blocks.
This code uses async/await
syntax to print strings asynchronously:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function printString(str, delay) { setTimeout(() => console.log(str), delay); } const myAsync = async () => { await printString('one', 3); await printString('two', 2); await printString('three', 1); } myAsync(); // three // two // one |
For comparison, this code uses a Promise
object to do the same:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function printString(str, delay) { setTimeout(() => console.log(str), delay); } new Promise((resolve, reject) => { printString('one', 3) }) .then(printString('two', 2)) .then(printString('three', 1)); // three // two // one |
To use an anonymous function with the async
keyword, we can wrap the function in an IIFE:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function printString(str, delay) { setTimeout(() => console.log(str), delay); } (async () => { await printString('one', 3); await printString('two', 2); await printString('three', 1); })(); // three // two // one |
The advantage of async
/await
syntax is that it looks more like synchronous code than an explicitly created Promise
object does. Each of the calls to printString
looks the same, and the initial Promise
object gets created “under the hood.”
Other Features of the Promise Object
The Promise object has a number of other important features. Of these, I will touch upon the methods that support async task concurrency.
The most commonly used of these is the Promise.all
method. This method accepts an array of Promise
objects, and returns a single Promise
object. This Promise
object fulfills if and when all of the input Promise
objects fulfill, and rejects if any of the input Promise
objects reject.
Other concurrency methods include any
, allSettled
and race
.
For more information, see the documentation for the Promise
object.