Understanding async/await in JavaScript

async/await is a mechanism in JavaScript that makes working with asynchronous IO easier. But it seems to be hard to understand. In this article, I will introduce my intuitive understanding of async/await.

Idea

In our daily work, our tasks may contain not only the action part but also the waiting part. Sometimes, we need to wait for an event such as the response from someone else or our other task to be finished before the task can continue.

To improve efficiency, we may have multiple tasks in progress at the same time. If the current task reaches the waiting part, we may switch to another task, and go back later. In this way, we make use of the waiting time of one task by doing other tasks. We can also save time by waiting for multiple events at the same time.

The concept of asynchronous and concurrent are based on this idea. If some tasks are in progress at the same time, we say these tasks are executed concurrently. We call the task that can be executed concurrently with others an asynchronous task. In JavaScript, most of the IO operations, the operations whose major parts are "waiting", are asynchronous. The tranditional IO function starts a new asynchronous task that does the IO operation then executes the call-back function. The caller continue to execute once the IO operation reaches the waiting part. The caller may start another asynchronous IO operation which is executed concurrently with other unfinished IO operations.

However, in traditional JavaScript, it is not convienient to perform an asynchronous IO operation and wait for it in the middle of a task. We have to split the task into many call-back functions, which can make the code hard to write and hard to understand. The async/await feature is to solve this issue. We introduce promise object to represent an asynchronous task. We can mark the function that starts an asynchronous task and returns a promise with async. Inside the function, we implement the task, and we can use await on a promise object to wait for a the corresponding task and get its output. The a in await indicates that the waiting is for asynchronous task, which means, we will try to switch to another in-progress asynchronous task while waiting. [1]

Promise

As mentioned earlier, we use a promise object to represent an asynchronous task that has started. At a certain time, a promise can be

  • resolved with a value, i.e. a successful result,
  • rejected with a reason, i.e. failed for a reason,
  • not finished yet.

In TypeScript, the promise has a Promise<T> type where T is the resolve value type.

Beside accepting a call-back function, an asynchronous IO function can return a promise object to represent the IO task. There are some libraries providing such API, e.g. fs/promises. You can also use new Promise(...)[2] to turn a call-back style API to a promise.

async/await

Now we want to build a more complex asynchronous task that includes waiting for some other tasks. It's where async/await becomes useful. You can mark the function or lambda with async keyword to indicate that it starts a new task. The async keyword has the following effects.

  • The function always returns a promise, representing the started task. In TypeScript, the return type has to be Promise<T> where T can be any type.
  • The function body becomes what the task does. In the function body,
    • the value returned become the promise resolve value.
    • the error thrown become the promise reject reason.
    • you can use await on a promise object, which waits for the task, either giving you the resolved value or throwing the reject reason.

Here is an example of async and await.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const assert = require("assert");

// Suppose this is an IO function.
function read1() {
// Promise.resolve returns a `Promise` that is immediately resolved by the given value.
return Promise.resolve("read1");
}

// The async function always has a promise result.
async function read(source) /*: Promise<string> */ {
if (typeof source === "string" && source.length > 0) {
// The value returned becomes the resolved value of the pormise.
return "read from " + source;
}
// The error thrown becomes the rejected error of the `Promise`.
// Note that throwing a string may not be a good idea, but let's keep it simple in the example.
throw "invalid source";
}

async function task1() {
// await on the promise object to wait for the result.
// In TypeScript, `r1` has the type string as the `read1` function returns a `Promise<string>`.
const r1 /* : string */ = await read1();
assert(r1 === "read1");

const r2 = await read("file");
assert(r2 === "read from file");

// throw in the async function become the reject reason.
// Just calling the async function won't throw.
// Instead, we can use the `catch` method on the returned promise to handle the error.
read(undefined)
.catch(e => assert(e === "invalid source"));

try {
// await the promise that becomes rejected throws the reason.
await read(undefined);
} catch (e) {
assert(e === "invalid source");
}
}

If you want to use await at top-level, you can create an async lambda and immediately execute it, like this

1
2
3
(async () => {
// Use ``await` here
})()

To elaborate that the tasks are indeed executed concurrently, let's create an example that starts 1000 tasks of performing an IO operation twice. Here we use setTimeout to simulate a long-waiting IO.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function longIO() {
return new Promise((resolve) => setTimeout(() => resolve(undefined), 2000));
}

async function task(id) {
await longIO();
console.log("Task #" + id + " has finished its first longIO");
await longIO();
console.log("Task #" + id + " has finished its second longIO");
}

for (let i = 0; i < 1000; ++i) {
task(i);
}

Here we execute task function but do not await on it. This makes it starts the 1000 tasks immediately. Running this code, you should notice that after roughly 2 seconds, 1000 Task #n has finished its first longIO printed, then after roughly 2 seconds, 1000 Task #n has finished its second longIO printed. This shows that the tasks are executed concurrently. They wait for the longIO at the same time and they are in progress at the same time.

Summarize

This article briefly introduces the idea of async/await and discusses how to achieve concurrency. Because I'm too lazy I don't want this article to be too long, I didn't go into all the details. I hope this article dispels your fear of async/await.

Explainations used in this article

  • concurrent: multiple tasks are executed concurrently if they are being progressed at the same time.
  • asynchronous: the task that can be executed concurrently with other tasks.
  • promise: an asynchronous task that has started. It may be in progress, resolved, or rejected.
  • async: the keyword to mark the function that starts an asynchronous task and returns a promise. You can use await in the function body.
  • await: wait for a promise to finish.

  1. DISCLAIMER: this explaination of the name await is unofficial. ↩︎

  2. For more information, check the document of Promise constructor. ↩︎