§7.3.

Callbacks and async / await

So far, these notes have included several examples of passing functions as parameters to other functions:

  • Starting an express server:

    app.listen(port, () => console.log(`The shopping list is running on http://localhost:${port}/`));
  • Adding a handler for an Express.js route:

    app.get('/new', (req, res) => {
        // Get the user input
        const description = req.query['description'];
        const quantity = parseInt(req.query['quantity']);
    
        // Add to the shopping list
        items.push({ description, quantity });
    
        // Redirect back to the home page to show the full list
        res.redirect('/');
    });
  • Using the Fetch API:

    // Find all items in the shopping list
    function queryItems(callback) {
        fetch('/api/items')
        .then(response => response.json())
        .then(result => callback(result.items, result.total));
    }

Another common example that occurs in JavaScript programming, is using setTimeout to run a function after a delay:

console.log('Shown immediately');
setTimeout(
    () => console.log('Shown after two seconds (2000 milliseconds)'),
    2000
);

What all these examples have in common is a separation, in time, of the execution of code. An action starts by calling app.listen, app.get, fetch and setTimeout. The supplied function is invoked sometime later when the result or request is ready.

This supplied function is known as a callback and is a common way of dealing with tasks that take a long time. Callbacks avoid the problem of code getting ‘stuck’ waiting for slow services.

To compare JavaScript’s use of callbacks with other approaches, consider the following example. It uses the Python programming language to perform two requests:

import requests

r = requests.get('http://aws.amazon.com/')
print("Amazon has status code:", r.status_code)

r = requests.get('http://cloud.google.com/')
print("Google has status code:", r.status_code)

On a slow connection, the first request might take a long time. The second request will not start until the first request has finished. This wait for serial execution is said to be blocking code. [1]

Here is equivalent Node.js code:

// Fetch is automatically available in web browsers
// However, node-fetch is needed to use fetch from Node.js
const fetch = require('node-fetch');

fetch('http://aws.amazon.com')
.then(response => console.log('Amazon has status code:', response.status));

fetch('http://cloud.google.com')
.then(response => console.log('Google has status code:', response.status));

Running the equivalent JavaScript code multiple times highlights the difference with Python. Here’s what happens if I run the Node.js code on my computer:

$ node nodejs_statuses.js
Google has status code: 200
Amazon has status code: 200
$ node nodejs_statuses.js
Google has status code: 200
Amazon has status code: 200
$ node nodejs_statuses.js
Amazon has status code: 200
Google has status code: 200
$

Note that on the first two runs, Google’s server responded before Amazon, but on the third run, Amazon’s server responded first. Node.js is performing both requests simultaneously and executing the callbacks as soon as a result is available.

Callback “Christmas trees”

Unfortunately, callbacks can make code difficult to understand. The following script displays a timed countdown (“Three”, “Two”, “One”, “Go!”):

console.log('Three');
setTimeout(
    () => {
        console.log('Two');
        setTimeout(
            () => {
                console.log('One');
                setTimeout(
                    () => {
                        console.log('Go!');
                    },
                    1000
                );
            },
            1000
        );
    },
    1000
);

This is not readable code! The deep nesting of callbacks is jokingly known as “callback hell” or sometimes “callback Christmas trees” (because its shape resembles a Christmas tree on its side):

Rotated Christmas tree

Using async / await

The ECMAScript 2017 standards introduced async and await to JavaScript. These keywords vastly simplify code that involves callbacks.

Here is a timed countdown using async/await:

const delay = require('delay');

async function countdown() {
    console.log('Three');
    await delay(1000);
    console.log('Two');
    await delay(1000);
    console.log('One');
    await delay(1000);
    console.log('Go!');
}

countdown();

In JavaScript, async/await is syntactic sugar to make it easier to write callbacks:

  • await delay(1000) means something like: “turn the rest of the function into a callback and pass it to delay(1000).then(callback)

  • async function countdown() means something like: “this function uses await and expects a callback”

The await keyword is only permitted inside async functions. [2]

Promises

Internally, async/await depends on promises. In computing, a promise or a future refers to a placeholder representing a “promise” to return a value later.

Promises have been part of JavaScript since the ECMAScript 2015 standards. In JavaScript, a promise is a standardized way of defining callbacks that can be used consistently in libraries and with async and await.

In JavaScript, promises solve the problem of inconsistencies when passing callbacks as parameters. For example, in setTimeout(callback, milliseconds), the callback is the first parameter. In app.listen(port, callback), the callback is the last parameter. Instead, a function that expects a callback must return a Promise, which has a consistent .then(successCallback, failureCallback) method. [3]

I apologize if this terminology is confusing. The simplest way to think about promises is that instead of writing delay(1000, callback), you write delay(1000).then(callback).

Exercise: Output prediction

Create a new project and install delay (i.e., use the commands npm init and npm install delay). Run the code below and examine the output.

const delay = require('delay');

// Create a promise
// a value is returned immediately (a Promise)
// but that promise takes 2 seconds to resolve
let x = delay(2000);

// The value in x is a Promise
console.log(x);

// Set a callback on the promise
// This callback will be invoked when the promise is resolved
let y = x.then(() => console.log('Two seconds elapsed'));

// The value in y is also a Promise
console.log(y);

console.log('Finished script')

// Finally, "Two seconds elapsed' will print when the promise resolves itself, before Node.js exits

What is the output? What does the output tell you about how promises work in JavaScript?

Tip

The following terminology describes the state of a promise:

  • Pending: the initial state, with no result yet

  • Fulfilled: the operation completed successfully, with a result

  • Rejected: the operation failed, with a result (e.g., an exception/error)

The Promise class in JavaScript has several static helper functions. It provides methods to create promises that are instantly fulfilled or rejected:

  • Promise.reject(error) creates a promise that is immediately rejected with error

  • Promise.resolve(value) returns a resolved promise with the result value

The Promise class also has helper functions to resolve multiple promises at once:

  • Promise.race(iterable) returns the result of the first promise to resolve.
    For example, Promise.race([delay(1000), delay(2000), delay(3000)]) is fulfilled in one second because delay(1000) finishes first.

  • Promise.all(iterable) waits for all the promises to resolve.
    For example, Promise.all([delay(1000), delay(2000), delay(3000)]) is fulfilled in three seconds because all start simultaneously but delay(3000) finishes last.

Promises, async/await and fetch

Promises in JavaScript establish a standard convention for passing callbacks. This standardization makes it possible for JavaScript to translate code involving await into callbacks that invoke .then(…​).

Async/await supports any API that returns promises (or objects with .then methods). fetch is an obvious candidate. Async/await will simplify the presentation logic code responsible for retrieving data from a server.

For example, recall the queryItems function in the single-page application:

// Find all items in the shopping list
function queryItems(callback) {
    fetch('/api/items')
    .then(response => response.json())
    .then(result => callback(result.items, result.total));
}

The function is simpler with async/await:

// Find all items in the shopping list
async function queryItems(callback) {
    let response = await fetch('/api/items')
    let result = await response.json())
    return result;
}
Tip
Code using async/await is usually easier to read than callbacks. Async/await code is sequential and does not have nested callbacks. Internally, JavaScript uses callbacks, so there are no problems with blocking code.

I find async/await to be very useful as a developer. Most modern packages available for Node.js support promises. However, when I search for new libraries, I make sure that long-running functions have support for promises, rather than callbacks.

Async/await will appear in the next chapter when I discuss databases. Database queries can involve network communication and long-running queries. Database client libraries that support promises allow for simpler code.

Tip
The reason fetch requires two calls to await is because there are two things that you may wait on as a developer. The headers and status are available after the first await, while the complete response is available after the second await. There are alternatives to fetch that work with a single await, such as axios or Angular’s @angular/common/http library.

1. Python uses threading to address the problem of blocking code.
2. Arrow functions (=>) can also be used with async/await. For example, async () => await delay(1000).
3. Any object with a .then(…​) method is compatible with promises. JavaScript sometimes refers to such objects as being “thenable”.