Eda Eren

March 7, 2023
  • JavaScript

async and await: Promises Simplified

Promises in JavaScript were there to save the day from being stuck in callback hell. But the thing is, .then() chains could eventually turn into a hell of their own. Since the human mind works synchronously, it might be hard to grasp asynchronous code when it becomes too tangled. Luckily, we have yet another thing to save the day — async and await!

We can write asynchronous functions that look like synchronous code with the keyword async in front of the function keyword:

async function doStuff { /* body */ }
async function doStuff { /* body */ }

Async functions always return a promise implicitly, even if you do something like this:

async function getTheMeaning() {
return 42;
}
async function getTheMeaning() {
return 42;
}

What is returned will be a promise with the resolved value of 42. It might look similar to the piece of code below (there will come a "but"):

function getTheMeaning() {
return Promise.resolve(42);
}
function getTheMeaning() {
return Promise.resolve(42);
}

But, they are not equivalent. The subtlety lies in the references these two functions will point to for a given promise. For example, let's create a new promise that both the plain function and the async function will resolve:

let theMeaning = new Promise((resolve, reject) => {
resolve(42);
});

function plainGetTheMeaning() {
return Promise.resolve(theMeaning);
}

async function asyncGetTheMeaning() {
return theMeaning;
}
let theMeaning = new Promise((resolve, reject) => {
resolve(42);
});

function plainGetTheMeaning() {
return Promise.resolve(theMeaning);
}

async function asyncGetTheMeaning() {
return theMeaning;
}

When we inspect if they are pointing to the same value, we see that they behave differently:

console.log(theMeaning === plainGetTheMeaning()); // true
console.log(theMeaning === asyncGetTheMeaning()); // false
console.log(theMeaning === plainGetTheMeaning()); // true
console.log(theMeaning === asyncGetTheMeaning()); // false

So, in fact, maybe it is better to think of an async function as a function that returns a promise, and wrapped around our original function, like this one:

function getTheMeaning() {
return new Promise(function(resolve, reject) {
try {
resolve((function() { return 42; } )());
}
catch(e) {
reject(e);
}
});
}
function getTheMeaning() {
return new Promise(function(resolve, reject) {
try {
resolve((function() { return 42; } )());
}
catch(e) {
reject(e);
}
});
}

Now in this case, we see that it is not like plainGetTheMeaning(), and much more like the async function itself when it comes to references:

let theMeaning = new Promise((resolve, reject) => {
resolve(42);
});

function getTheMeaning() {
return new Promise(function(resolve, reject) {
try {
resolve((function() { return 42; } )());
}
catch(e) {
reject(e);
}
});
}

console.log(theMeaning === getTheMeaning()); // false
let theMeaning = new Promise((resolve, reject) => {
resolve(42);
});

function getTheMeaning() {
return new Promise(function(resolve, reject) {
try {
resolve((function() { return 42; } )());
}
catch(e) {
reject(e);
}
});
}

console.log(theMeaning === getTheMeaning()); // false

async functions can also have await expressions. In fact, await can only be used within async functions (as long as it is not inside a module).

await basically waits for a promise to settle, and returns the fulfillment value of that promise. A simple example:

async function getToDo(toDoId) {
let response = await fetch(`https://jsonplaceholder.typicode.com/todos/${toDoId}`);
/*
toDo is going to be the json object:
{ userId: 1, id: 1, title: 'delectus aut autem', completed: false }
*/
let toDo = await response.json();
return toDo.completed;
}
async function getToDo(toDoId) {
let response = await fetch(`https://jsonplaceholder.typicode.com/todos/${toDoId}`);
/*
toDo is going to be the json object:
{ userId: 1, id: 1, title: 'delectus aut autem', completed: false }
*/
let toDo = await response.json();
return toDo.completed;
}

Here, the return value itself will be a promise, so it feels like we need to use await for it. Like this:

let isCompleted = await getToDo(1); // Not really...
let isCompleted = await getToDo(1); // Not really...

But remember, await can be used inside another async function! Now that we can't use await, we need to handle this thing with our good friends .then() and .catch():

getToDo(1)
.then(isCompleted => console.log(isCompleted))
.catch(e => { throw e });
getToDo(1)
.then(isCompleted => console.log(isCompleted))
.catch(e => { throw e });

The important thing to point out is that await just waits for its promise to settle and pauses the execution of the function. It is what await does, it just waits. So if there are multiple await expressions in succession, each of them will be executed sequentially, instead of running in parallel. It could be a good thing if each of them depends on the expression before it, but when it is not the case, it might create a performance issue where the execution of the code is slower.

In the getToDo() example, todo needs response, so it makes sense to use await one after the other. But, let's say we have multiple URLs to fetch, multiple toDos, and they are all independent of each other. In that case, we want them to run in parallel. We can use Promise.all() with await to do that:

async function getToDos(toDoIds) {
let responses = toDoIds.map(async (id) => await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`));
let [response1, response2, response3] = await Promise.all(responses);
let toDos = await Promise.all([response1.json(), response2.json(), response3.json()])
return toDos.map(toDo => toDo.completed);
}

let ids = [1, 2, 3];

getToDos(ids)
.then(toDosCompletedStatus => console.log(toDosCompletedStatus))
.catch(e => { throw e });
async function getToDos(toDoIds) {
let responses = toDoIds.map(async (id) => await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`));
let [response1, response2, response3] = await Promise.all(responses);
let toDos = await Promise.all([response1.json(), response2.json(), response3.json()])
return toDos.map(toDo => toDo.completed);
}

let ids = [1, 2, 3];

getToDos(ids)
.then(toDosCompletedStatus => console.log(toDosCompletedStatus))
.catch(e => { throw e });

Inside getToDos(), it seems like a lot going on, but it is actually simple to reason about. We take an array of ids as argument, and map them to their fetch responses of their corresponding URLs. Here, notice that we use an arrow function inside .map() that is defined as async. Then, we use await Promise.all(responses) to get the fulfillment values of responses. After that, we get each response's .json() value, since .json() returns a promise, we again use Promise.all() with await. At this point, what we have is the fulfillment values of toDos, and we map them to the completed property of each.

And, here is the beauty of async and await, our code looks like it is synchronous, hence easier to read and think about.

Although using async and await is just a "better" way to use promises, and there is almost no difference between them, async and await might have a slight performance advantage when it comes to V8 JavaScript engine as the stack trace is not captured and stored when using await. Read more about it here: https://mathiasbynens.be/notes/async-stack-traces

We have seen that promises were there to save us from callback hell, and that async functions make our code even better and simpler for us to read and write. Remember that there is always a "better" solution, depending on how you look at it, and of course, there is always the spec and perhaps the friendlier docs to consult. 💜