This chapter is included in the free preview

Async functions

Normal functions return a result with the return keyword:

const fn = () => {
  return 5;
}

fn();
// 5

Async functions, in contrast, return a Promise:

const asyncFn = async () => {
  return 5;
}

asyncFn();
// Promise

You can make any function async with the async keyword, and there are a lot of functions that return a Promise instead of a result.

For example, to read a file from the filesystem, you can use fs.promises, a variant of the fs functions returning Promises:

const fs = require("fs");

fs.promises.readFile("test.js");
// Promise

Or convert an image to jpeg with the sharp library, which also returns a Promise:

const sharp = require("sharp");

sharp("image.png")
  .jpeg()
  .toFile("image.jpg");
// Promise

Or make a network request with fetch:

fetch("https://advancedweb.hu");
// Promise

How to use the Promise

An async function still has a return value, and the Promise holds this result. To get access to the value, attach a callback using the then() function. This callback will be called with the result of the function.

To get the file contents after the readFile:

const fs = require("fs");

fs.promises.readFile("test.js").then((result) => {
  console.log(result);
  // Buffer
});

Similarly, to get the result of our simple async function, use then():

const asyncFn = async () => {
  return 5;
}

asyncFn().then((res) => {
  console.log(res);
  // 5
});

The benefits of Promises

But why complicate a function call with Promises? A normal function just returns a value that can be used on the next line, without the need for any callbacks.

The benefit of returning a Promise instead of a value is that the result might not be ready by the time the function returns. The callback can be called much later, but the function must return immediately. This extends what a function can do.

// sync
fn();
// result is ready

// async
asyncFn().then(() => {
  // result is ready
})
// result is pending

In many cases, a synchronous result is not possible. For example, making a network request takes forever compared to a function call. With Promises, it does not matter if something takes a lot of time or produces a result immediately. In both cases, the then() function will be called when the result is ready.

An async function returns a Promise

Using standardized Promises also allows other constructs to build on it. As we've seen, the async keyword makes a function return a Promise instead of a value.

Recap

Async functions return Promises which are values that are available sometime in the future.

The await keyword

Using Promises with callbacks requires changes to the code structure and it makes the code harder to read.

Instead of a flat structure of synchronous function calls:

const user = getUser();
const permissions = getPermissions();
const hasAccess = checkPermissions(user, permissions);
if (hasAccess) {
  // handle request
}

Promises need callbacks:

getUser().then((user) => {
  getPermissions().then((permissions) => {
    const hasAccess = checkPermissions(user, permissions);
    if (hasAccess) {
      // handle request
    }
  });
});
Note

Promises support a flat structure when they are invoked sequentially, this is their main selling point over traditional callbacks. For example, a series of async calls can process an object:

getUser()
  .then(getPermissionsForUser)
  .then(checkPermission)
  .then((allowed) => {
    // handle allowed or not
  });

This is an almost flat structure which we'll detail in the Chaining Promises chapter, but a callback is still needed at the end. The await keyword eliminates the need for that.

To solve this without losing the advantages of Promises, async functions can use the await keyword. It stops the function until the Promise is ready and returns the result value.

const asyncFn = async () => {
  return 5;
}

await asyncFn();
// 5

The above code that uses callbacks can use await instead which leads to a more familiar structure:

const user = await getUser();
const permissions = await getPermissions();
const hasAccess = checkPermissions(user, permissions);
if (hasAccess) {
  // handle request
}

The await keyword that waits for async results makes the code look almost like it's synchronous, but with all the benefits of Promises.

Note that await stops the execution of the function, which seems like something that can not happen in JavaScript. But under the hood, it still uses the then() callbacks and since async functions return Promises they don't need to provide a result immediately. This allows halting the function without major changes to how the language works.

Async function examples

Async functions with await are powerful. They make a complicated and asynchronous workflow seem easy and familiar, hiding all the complexities of results arriving later.

Browser automation is a prime example. The Puppeteer project allows starting Chromium and driving it with the DevTools protocol.

Taking a screenshot of a webpage is only a few lines:

const browser = await puppeteer.launch(options);
const page = await browser.newPage();
const response = await page.goto(url);
const img = await page.screenshot();
await browser.close();

This code hides a lot of complexity. It starts a browser, then sends commands to it, all asynchronous since the browser is a separate process. But the end result is an image buffer containing the screenshot.

Note

The above code leaves the browser running if there is an error during execution. You'll learn how to handle closing resources in the The async disposer pattern chapter.

Another example is to interface with databases. A remote service always requires network calls and that means asynchronous results.

This code, used in an AWS Lambda function, updates a user's avatar image:

// move object out of the pending directory
await s3.copyObject({/*...*/}).promise();
await s3.deleteObject({/*...*/}).promise();

// get the current avatar image
const oldAvatar = (await dynamodb.getItem({/*...*/}).promise())
  .Item.Avatar.S;

// update the user's avatar
await dynamodb.updateItem({/*...*/}).promise();
// delete the old image
await s3.deleteObject({/*...*/}).promise();

return {
  statusCode: 200,
};

It makes calls to the AWS S3 service to move objects, and to the DynamoDB database to read and modify data. Both of these are remote services, but all the complexities are hidden behind the awaits.

Recap

The await keyword stops the function until the future result becomes available.

Chaining Promises

We've seen that when an async function returns a value it will be wrapped in a Promise and the await keyword extracts the value from it. But what happens when an async function returns a Promise? Would that mean you need to use two awaits?

Consider this code:

const f1 = async () => {
  return 2;
};

const f2 = async () => {
  return f1();
}

const result = await f2();

Strictly following the process described in the previous chapters, f1() returns Promise<2>, and f2() returns Promise<Promise<2>>, so the value of result will be Promise<2> instead of 2.

But this is not what happens. When an async function returns a Promise, it returns it without adding another layer. It does not matter if it returns a value or a Promise, it will always be a Promise and it will always resolve with the final value and not another Promise.

This works the same for Promise chains too. The .then() callback is also wrapped in a Promise if it's not one already so you can chain them easily:

getUser()
  .then(async function getPermissionsForUser(user) {
    const permissions = await // ...;
    return permissions;
  })
  .then(async function checkPermission(permissions) {
    const allowed = await // ...;
    return allowed;
  })
  .then((allowed) => {
    // handle allowed or not
  });

The getUser function returns a Promise<User>, then the getPermissionsForUser gets the user object (the resolved value), then returns the permission set. The next call, checkPermission gets the permissions, and so on.

A useful analog is how the flatMap function for an array works. It does not matter if it returns a value or an array, the end result will always be an array with values. It is a map, followed by a flat.

[1].flatMap((a) => 5) // [5]
[1].flatMap((a) => [5]) // [5]

[1].flat() // [1]
[[1]].flat() // [1]

When I'm not sure what a Promise chain returns, I mentally translate Promises to arrays where every async function return a flattened array with its result and an await is getting the first element:

const f1 = () => {
  return [2].flat();
};

const f2 = () => {
  return [f1()].flat();
}

const result = f2()[0]; // 2

A Promise chain then becomes a series of flatMaps:

const getUser = () => ["user"];

getUser()
  .flatMap(function getPermissionsForUser(user) {
    // user = "user"
    const permissions = "permissions";
    return permissions;
  })
  .flatMap(function checkPermission(permissions) {
    // permissions = "permissions"
    const allowed = true;
    return allowed;
  })
  .flatMap((allowed) => {
    // allowed = true
    // handle allowed or not
  });

This eliminates most of the complexities of asynchronicity and is a lot easier to reason about.