Controlling sequential asynchronous operations with async/await and Promises
Promises were introduced in ES2015 (ES6), along with other modern data structures.
For those familiar with JavaScript prior to ES2015, asynchronous behavior was modeled with callback-based interfaces, for example, request(url, (error, response) => { /* do work with response */ })
. The key issues that Promises resolved were the chaining of asynchronous requests and issues around managing parallel requests, which we’ll cover in this section.
ES2016 included the initial specification for the async/await syntax. It built on top of the Promise object in order to write asynchronous code that didn’t involve “Promise chains,” where different Promises are processed using the Promise().then
function. Promise functionality and async/await interoperate nicely. In fact, calling an async function returns a Promise.
We’ll start by showing how to use Promises to manage sequential asynchronous operations. We’ll use the Fetch API (which returns a Promise) to load fakestoreapi.com/auth/login
. Given a username and password, and based on the output, we’ll load all the relevant carts for that user. Subsequently, we’ll load the relevant carts for that user using the fakestoreapi.com/carts/user/{userId}
endpoint. This request flow is visualized in the following diagram.
Figure 7.1: Sequence of /auth/login and /carts/user/{userId} requests
We’ll start by sending a POST request to the auth/login
endpoint. We add .then((res) => res.json())
, which will wait for the initial fetch()
output Promise to resolve to a “response” (hence the res
name). We then call the .json()
method on the response, which again is a Promise, which resolves to the JSON-decoded response body:
function fetchAuthUserThenCartsPromiseThen(username, password) { return fetch('https://fakestoreapi.com/auth/login', { method: 'POST', body: JSON.stringify({ username, password, }), headers: { 'Content-Type': 'application/json', }, }).then((res) => res.json()); }
The Promise returned from res.json()
can be accessed in another .then()
callback, in which we parse the token
field, which is a JSON Web Token (JWT), using the jwt-decode
package.
We extract the sub
field from the decoded token. This is the “subject” claim, which tells us which user this token is about. In the case of the fakestoreapi
token, userId
is used as the “subject” claim. We can therefore use the sub
claim as the user ID for which to load the carts in our following API call to https://fakestoreapi.com/carts/user/{userId}
:
import jwt_decode from 'https://esm.sh/jwt-decode'; function fetchAuthUserThenCartsPromiseThen(username, password) { return // no change to the fetch() call .then((res) => res.json()) .then((responseData) => { const parsedValues = jwt_decode(responseData.token); const userId = parsedValues.sub; return userId; }) .then((userId) => fetch(`https://fakestoreapi.com/carts/user/${userId} ?sort=desc`) ) .then((res) => res.json()); }
This function can then be used as follows. Note that a password shouldn’t be stored in the source of a production application (as it is in this example).
When we call the fetchAuthUserThenCartsPromiseThen
function, it makes both the /auth/login
call and then the /carts/user/{userId}
call, which means we receive an array with the relevant carts for the requested user (note userId
= 3
, which is the correct ID for the kevinryan
user).
Note that we’re using async/await here to “flatten” the Promise output into userCartsDataPromiseThen
, which we can assert on:
const username = 'kevinryan'; const password = 'kev02937@'; const userCartsDataPromiseThen = await fetchAuthUserThenCartsPromiseThen( username, password ); assert.deepEqual(userCartsDataPromiseThen, [ { __v: 0, date: '2020-01-01T00:00:00.000Z', id: 4, products: [ { productId: 1, quantity: 4, }, ], userId: 3, }, { __v: 0, date: '2020-03-01T00:00:00.000Z', id: 5, products: [ { productId: 7, quantity: 1, }, { productId: 8, quantity: 1, }, ], userId: 3, }, ]);
As we’ve just seen in the code that calls fetchAuthUserThenCartsPromiseThen
, the key benefit of async/await over Promise().then()
chains is that the code is structured more similarly to synchronous code.
In synchronous code, the output of an operation can be, for example, assigned to a constant:
const output = syncGetAuthUserCarts(); console.log(output);
Whereas with Promise().then()
, the output is available only in an additional .
then
callback:
promisifiedGetAuthUserCarts().then((output) => { console.log(output); });
What await
allows us to do is to structure the code as follows:
const output = await promisifiedGetAuthUserCarts(); console.log(output);
One way to think of it is that await
can unfurl Promises. A Promise’s “resolved value”, usually only accessible in a Promise().then()
callback is available directly.
For sequential operations, this is very useful, since it makes the code structured with a set of variable assignments per async operation.
The await
operator is available at the top level of ECMAScript modules in modern runtime environments as part of the ES2022 specification.
However, in order to use await
inside of a function, we need to mark the function as async
. This usage of await
in async
functions has been available since ES2016.
Code editors and IDEs such as Visual Studio Code provide a refactor from chained Promise().then()
calls to async/await. In our case, we can build a fetchAuthUserThenCartsAsyncAwait
function as follows.
Instead of using fetch().then(res => res.json())
, we’ll first use await fetch()
and then await authResponse.json()
:
async function fetchAuthUserThenCartsAsyncAwait (username, password) { const authResponse = await fetch('https://fakestoreapi.com/auth/login', { method: 'POST', body: JSON.stringify({ username, password, }), headers: { 'Content-Type': 'application/json', }, }); const authData = await authResponse.json(); }
We now have access to authData
. We can decode authData.token
as before using the jwt-decode
package. This gives us access to the sub
(subject) claim, which is the user ID:
Import jwt_decode from 'https://esm.sh/jwt-decode'; async function fetchAuthUserThenCartsAsyncAwait (username, password) { // no change to /auth/login API call code const parsedValues = jwt_decode(authData.token); const userId = parsedValues.sub; }
Now that we have the relevant user ID, we can call the /carts/user/{userId}
endpoint to load the user’s carts:
async function fetchAuthUserThenCartsAsyncAwait (username, password) { // no change to /auth/login call or token parsing logic const userCartsResponse = await fetch( `https://fakestoreapi.com/carts/user/${userId}?sort=desc` ); const userCartsResponseData = await userCartsResponse. json(); return userCartsResponseData; }
Given the same input data as the approach using Promise().then()
, the loaded carts are the same. Note, again, that passwords and credentials should not be stored in source code files:
const username = 'kevinryan'; const password = 'kev02937@'; const userCartsDataAsyncAwait = await fetchAuthUserThenCartsAsyncAwait( username, password ); assert.deepEqual(userCartsDataAsyncAwait, userCartsDataPromiseThen);
One difference between the approaches is that with async/await, all the variables are defined in a single function scope, whereas the Promise().then()
approach uses multiple function scopes (for each of the callbacks passed to .then()
). With a single large function scope, variable names can’t clash, which makes the code more verbose since, for example, each response
object needs a qualifier to avoid variable name clashes, for example, authResponse
and userCartsResponse
.
The benefit of a single larger function scope is that all the outputs of previous API calls are available to subsequent ones without having to explicitly set them as values passed as a return in the callback passed to .then()
.
Finally, a fetch()
-specific example, is that since there are multiple Promises that require handling when doing a fetch and accessing the JSON response, the await approach can be a bit “noisier.”
See the two following samples. First, with async/await, we assign a variable for the fetch response
value:
const response = await fetch(url); const data = await response.json();
Next, with .then()
, we assign only a data
variable and use an arrow function to handle the .
json()
unfurling:
const data = await fetch(url).then((response) => response.json());
As you see, our final example is a mix of async/await
and Promise().then()
so that the most “important” parts of the code are obvious. The specifics of how we extract the JSON output from fetch
are not necessarily core to our logic so might be better expressed with Promise().then()
.
In general, this slight difference in style wouldn’t occur since parts of the code that are “less important,” such as how we interact with the fetch API to process a request to JSON, tend to be abstracted – in this case, in an HTTP client of some kind. We would expect that the HTTP client could handle checking response.ok
and accessing the response body as parsed JSON (using response.json()
).
We’ve now seen how to implement sequential asynchronous operations using a Promise-only approach, an async/await-based approach, and finally, how both the async/await and Promise techniques can be used together to improve code readability and performance.