Javascript: Understanding async/await

Rohit Malhotra
TechShots
Published in
5 min readMay 24, 2019

async/await makes it easier to work with asynchronous code in javascript. Understand how it works and why we should use it is important to get the best out of it.

Photo by Joshua Aragon on Unsplash

To truly understand async/await, we need to go through the history of dealing with asynchronous code in javascript:

Callbacks

Let’s say we want to read a file, format its content, save it in another file and then print a success message.

This is how it will look using callbacks:

const fs = require('fs');fs.readFile('./doc.txt', (err, content) => {
const formattedContent = content.toString().toUpperCase();
fs.writeFile('./doc2.txt', formattedContent, (err) => {
if (!err) {
console.log('success!!')
}
});
});

readFile here accepts the path of the file that needs to be read and the callback function that it will execute after reading the file.

The callback function will then format the content and write the result into another file. writeFile here accepts the new file path, content and the callback function that will execute after the file is written to the disk.

The problem with callbacks is more apparent in the following:

getSomeData(() => {
getMoreData(() => {
doSomethingWithTheData(() => {
saveUpdatedData(() => {
refreshCache(() => {
logOutput()
})
})
})
})
});

Whenever we want to do more than 2–3 asynchronous operations one after the other, we will fall into callback hell. This can result in confusing code.

This is not to say that one should not use callbacks, there are ways to write clean and maintainable code with callbacks as mentioned in the link above.

Promises

A promise can be in one of the three states:

  1. Pending (initial state): An asynchronous operation has started which will eventually get resolved or rejected.
  2. Fulfilled: The asynchronous operation is completed successfully and the promise is resolved with the resultant value.
  3. Rejected: The asynchronous operation failed, the promise is rejected with the error that caused it to fail.

Promisifying libraries that work with callbacks only:

const fs = require('fs');const readFile = (path) => {
return new Promise(function(resolve, reject) {
fs.readFile(path, (err, content) => {
if (err) {
reject(err);
}
resolve(content);
})
});
};
const writeFile = (path, content) => {
return new Promise(function(resolve, reject) {
fs.writeFile(path, content, (err) => {
if (err) {
reject(err);
}
resolve();
})
});
};

Since some javascript libraries only work with callbacks, we can write our own wrappers around them to return promises as shown above. Popular libraries like bluebird can do this for you, along with many other useful features over native promises. If you are using Node version 8.x or above, util.promisify can also be used.

This is how the files example would look like when done with promises:

const Promise = require('bluebird');
const fs = Promise.promisifyAll(require('fs'));
fs.readFileAsync('./doc.txt')
.then(content => {
const formattedContent = content.toString().toUpperCase();

return formattedContent;
})
.then(content => {
return fs.writeFileAsync('./doc2.txt', content);
})
.then(() => {
console.log('success!!');
})
.catch(err => {
console.log(err);
});

Using promises, then chains can be used to easily modularize code making it much more readable and maintainable.

Few points to note here:

  • A promise has a then function that accepts a callback that will execute after the promise is resolved.
  • The then function always returns a promise, allowing us to make a then chain.
  • A promise also has a catch function that can be used to catch errors at the end of the then chain. This way we don’t have to handle error for each async operation individually as we have to do with callbacks.
  • then functions, like all other functions in javascript, have their own scoping, a variable initialized in a then function can only be used in that scope.

The performance overhead when promises are processed and chained over some massive data can be considerable. A callback-based approach doing exactly the same would not have that overhead. Thanks, Dmitri Zaitsev for pointing this out.

Async/await

async/await is built on top of promises. What makes this way of dealing with promises popular is that it makes the asynchronous code look much more like synchronous code.

This is how the files example looks like when done with async/await :

const Promise = require('bluebird');
const fs = Promise.promisifyAll(require('fs'));

(async () => {
try {
const content = await fs.readFileAsync('./doc.txt');
const formattedContent = content.toString().toUpperCase();

await fs.writeFileAsync('./doc2.txt', formattedContent);

console.log('success!!');
} catch (err) {
console.log(err);
}
})();

fs.readFileAsync will return a promise just like before. await will stop the code execution for this function till that promise is resolved or rejected.

Note: When I say code execution will get stopped, it’s important to remember that async/await is using promises internally. So the file read operation is not actually done in a blocking way. Only the function execution stops till the awaited promise is resolved/rejected.

Then we format the content and write that into another file. Similarly, here await will wait for the promise returned by fs.writeFileAsync to resolve or reject.

Few points to note here:

  • await can only be used inside a async function.
  • An async function always returns a promise. Does not matter what the function does. If it’s async, it will return a promise.

Advantages of async/await

  • Clean code: As can be seen from the files example, async/await really makes the code cleaner and easy to read.
  • Error handling: We can handle both asynchronous and synchronous errors using try/catch in an async function as can be seen in the files example above.
  • Debugging: When debugging then chains, if you place a breakpoint inside a then function and then step over the code, it will not go into the next then function since step overs only work with synchronous code. With async/await it will step over awaits just like it does in synchronous code.

Avoiding common pitfalls

  • try/catch will only catch the error of an async call if the function awaits for it.
  • It’s easy to abuse await, always execute async calls in parallel unless they need to be done synchronously.
  • Do not use await in for loops if the async calls can be done in parallel.
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

const getNumPromise = async (num) => {
await sleep(Math.random() * 1000);
return num;
};

const numbers = [ 1, 2, 3, 4, 5 ];

(async () => {
for(let i = 0; i < numbers.length; i++) {
console.log(await getNumPromise(numbers[i]));
}
})();
// Will always print in order: 1 2 3 4 5

(() => {
numbers.forEach(async num => console.log(await getNumPromise(num)))
})();
// Will print randomly since calls are done in parallel: 3 2 4 1 5

Also, notice here how forEach behaves differently than a normal for loop. forEach actually accepts a function that it will execute for each element, so even though we await the promise in the function, it will be done parallelly for all elements.

Conclusion

async/await is a great way to deal with asynchronous code in javascript. It works on top of promises. So, now you know when and how to use them. If you have any questions or feedback, let me know in the comments down below.

--

--