When async/await might not be the answer
When async/await was introduced to ECMAScript it was generally pretty celebrated. Nearly every engineer I spoke to raved about the feature, how it “finally” made asynchronous operations in JS not confusing.
I’ve been using async/await professionally for a couple of years now. Through trial and error, I feel I have a good understanding of when to use it, and when it isn't the best hammer with which to whack your asynchronous nails.
In no particular order I’m going to cover a few of the situations where async/await can be detrimental to software and software engineers.
Making every function async
async/await
lets you turn a function into an asynchronous one simply by throwing the word async
near the front. So this:
function doTheDo() {
const result = someBoringSynchronousStuff()
console.log(result)
}
can become:
async function doTheAsyncDo() {
const result = await doCoolAsyncThings()
console.log(result)
}
Neat. A couple of little keywords let us treat totally asynchronous data as though it were synchronous.
However, a problem I’ve seen all too frequently is of people throwing on the magic async
keyword everywhere they can because “hey, I might need to access some asynchronous data in there one day, so I might as well make it async now.” The biggest problem with this is in how async/await code gets transpiled for use in browsers that don’t support it.
Given the doTheAsyncDo
function implementation above, the result of passing this through Babel’s Env Preset (a tool commonly used to compile modern JS down to JS supported by older browsers) is as follows:
function doTheAsyncDo() {
return _doTheAsyncDo.apply(this, arguments);
}
function _doTheAsyncDo() {
_doTheAsyncDo = _asyncToGenerator(
/*#__PURE__*/
regeneratorRuntime.mark(function _callee() {
var result;
return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
_context.next = 2;
return doCoolAsyncThings();
case 2:
result = _context.sent;
console.log(result);
case 4:
case "end":
return _context.stop();
}
}
}, _callee);
}));
return _doTheAsyncDo.apply(this, arguments);
}
That’s also excluding a heap of boilerplate functions that are reused between all async
functions. Point is, that’s quite a bit of code sent to the browser. If you’re dealing with fully synchronous operations, there’s no advantage in sending all that extra stuff down to the browser. Even if a function contains no asynchronous logic, if you use the async
keyword, you’ll have to treat the function as asynchronous — await
ing, .then
ing, and so on — which adds additional complexity for no gain.
Blocking execution with await
Once you get the hang of using await
, it can become second nature to just throw on the keyword so you can continue writing your code as though it were any old bit of synchronous code. Check out the following example:
const user = await getUser()
const post = await getPost()
However, there’s a (potentially) significant performance concern with this implementation: await getUser()
is blocking execution of await getPost()
, despite the fact the two have no dependence on each other. In a real world implementation these async functions could be calls to APIs, ORMs, or other services which can have latencies in the order of milliseconds through to seconds, and sometimes more.
The solution to this is to expand beyond the realm of using just async/await
and to look back to the definitely not redundant Promise
implementation. In particular, Promise.all
:
const [user, post] = await Promise.all([getUser(), getPost()])
In this implementation, Promise.all
is the async function that we wish to await. It will resolve when all the promises passed to it are resolved (or will throw if any fail). But notably, each of the functions passed don’t block one another, and will resolve concurrently.
Here’s a small reproduction of this issue:
The above could also be implemented using promises alone:
Promise.all([getUser(), getPost()]).then(([user, post]) => {
// do things with user and post
})
Without any transpilation, these two implementations are functionally equivalent, and the right one to use is ultimately down to the specific codebase and code styles.
Missing out on the readability of Promise chains
async/await
may appear to be the latest and greatest thing, but it’s not intended to replace Promises, for a number of reasons. Take the below example:
try {
const accessToken = await getAccessToken()
const post = await getPost(accessToken)
const formattedPost = formatPost(post)
sharePost(formattedPost)
} catch (e) {
console.error(e)
process.exit(1)
}
This above code calls a number of functions, and uses the result in each successive function. I.e. a chain of operations. Used correctly, we can leverage the .then
of Promises to create a much more elegant chain:
getAccessToken()
.then(getPost)
.then(formatPost)
.then(sharePost)
.catch(error => {
console.error(e)
process.exit(1)
})
Notable advantages:
- Each of the
.then
calls can be read as plain English out loud (try shouting it at your colleague!) - We don’t have to keep a whole heap of variables that we only use once (
accessToken
,post
,formattedPost
) - We don’t have to think up names for those above variables either
- We don’t have to chop and change between adding/dropping the
await
prefix between async/non-async functions because.then
will return Promises every time
Conclusion
I didn’t write this with a goal of saying “async/await
is bad and you should never use it”. Rather, I think async/await
can be a powerful language feature when used correctly. In order to take advantage of its benefits and avoid the pitfalls, I encourage developers to think twice before reaching for those keywords, and consider the implications in regards to performance, scalability, and semantics.
Resources
Thanks to Hinchy for proofreading/editing