Javascript: Understanding async/await
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.
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:
- Pending (initial state): An asynchronous operation has started which will eventually get resolved or rejected.
- Fulfilled: The asynchronous operation is completed successfully and the promise is resolved with the resultant value.
- 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 athen chain
. - A promise also has a
catch
function that can be used to catch errors at the end of thethen 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 aasync
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 athen
function and then step over the code, it will not go into the nextthen
function since step overs only work with synchronous code. Withasync/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.