A better async/await approach

Photo by Suganth on Unsplash

When there is even a linting rule to enforce a pattern, it usually means that pattern has indeed some issue, so lets see why return await ... is bad.

When to use await

The await keyword can be used either if the top level context is asynchronous, or if the current executing function is.

The peculiarity of await is that it works with any kind of returned value, even primitives.

(async () => {
// we can await any value or callback
const regular = () => 'regular';
// but we don't need to await returned promises
const promise = () => Promise.resolve('promise');
// we also should use `async` *only*
// if we need to `await` something
const asynchronous = async () => {
await regular();
// we don't need to await promise()
// we can return *just* promise()
return promise();
};
// let's test them all
console.log(await regular());
console.log(await promise());
console.log(await asynchronous());
})();

Now, beside stating the obvious, there are tons of developers that instead of writing the following:

const getData = async endPoint => {
const response = await fetch(endPoint);
return response.json(); // awesome 🎉
};

abuse the await keyword within the return statement:

const getData = async endPoint => {
const response = await fetch(endPoint);
return await response.json(); // WHY ??? 😩
};

This otherwise great post regarding useSWR pattern is just one example I’ve found today, but if we look closer, we’ll see that pattern widely spread … and what is wrong with that?

1. Performance

JSmicro-tasks” are very fast, but as everything they have a cost, and if we double them for no reason, we’ll have a “half as fast” execution, as result.

Let’s test these two scenarios:

const rand = async () => Promise.resolve(Math.random());
const arand = async () => await Promise.resolve(Math.random());
const randBench = async () => {
const randos = [];
console.time('rand');
for (let i = 0; i < 0xFFFF; i++)
randos.push(await rand());
console.timeEnd('rand');
return randos;
};
const arandBench = async () => {
const randos = [];
console.time('arand');
for (let i = 0; i < 0xFFFF; i++)
randos.push(await arand());
console.timeEnd('arand');
return randos;
};

Both randBench and arandBench can be executed in console, and in my i7 laptop, the results in Chrome speak for themselves:

randBench();
rand: 256.119873046875 ms
arandBench();
arand: 600.880859375 ms

Now, let’s be clear: these numbers are much different in Firefox or WebKit, as randBench() would be closer to 600ms in Firefox, while arandBench(); would be closer to 700ms, still in Firefox, but in WebKit the difference is unnoticeable (both benchmarks around 170ms), but as every micro benchmark should be taken with a pinch of salt, as these rarely reflect real-world code, and as engines might optimize for us the async dance in some smart way, the following point should help convincing us more.

2. Logic

If we’re not handling within our async callback any try / catch, awaiting a “thenable” becomes mostly pointless (I’ll explain the mostly bit in few seconds), because whatever code consumes an async callback, will either use await itself, or callback().then(...).

As the initially mentioned linting rule explains, handling try/catch is indeed the only case where return await ... makes sense (the “mostly” bit)

const getData = async endPoint => {
try {
const response = await fetch(endPoint);
return await response.json();
}
catch (error) {
console.error(error);
return {};
}
};

Our getData(...) now gracefully fallback to an empty object if either fetch(...) or response.json() fail, for whatever reason, providing a console error that might, or might not, be handy.

But then again, if we’re not handling errors within our async helpers and callbacks, the only thing that return await could do is to provide a stack slightly easier to debug … or does it?

In any case, even if awaiting a return actually provides a better debugging experience, are we sure we’re OK in slowing down by quite some margin the whole program because of possible unhandled promises?

Trying a better approach

A code full of unhandled errors has likely bigger issues than whatever return await brings to the plate, while performance, and clear code logic, are easily a better win for our app, but we could have both, preserving the stack during development, if that’s even needed, without penalizing performance in production, and the following helper is just a basic example of how we could do that:

const debug = async (callback, ...args) => {
try {
return await callback(...args);
}
catch (cought) {
const error = new Error(
callback.name || String(callback)
);
error.stack += '\n' + cought.stack;
throw error;
}
};

With this helper, we could now await callbacks like this:

const data = await debug(getData, endPoint);

The benefit of this approach is that the debug helper can become a no-op in production:

const debug = process.env.PRODUCTION ?
(callback, ...args) => callback(...args) :
() => { /* ... previous code ... */ };

and if we test this version within the randBench loop, we’ll see that performance is unaffected, but during development all stack details will be preserved. That’s it: all the details while developing and best perf in production, and if there is code around that would like to catch errors and handle these, everything should work pretty much as it did before.

Conclusion

The debugging argument of the return await is kinda the only point that makes sense, but I have the feeling the return await practice is rather a habit developers have, not an intent for better debugging.

As showed in this post, a tiny helper could let us easily improve what we’d like to better debug, without penalizing performance through extra, unnecessary, microtasks, once in production.

I personally prefer to catch when needed, or put asynchronous code that could fail within try/catch blocks, but I hope after this post some developer would think twice before writing return await. 👋

… but, it’s explicit!

Amending this post, as more than a developer mentioned that return await thing() is “an explicit developer intent to signal thing() is asynchronous”, so here the counter example:

const whatever = async value => await value;

Does anyone think the whatver callback explicitly states that value is asynchronous? Let’s give it a try:

await whatever('nope'); // works
await whatever(false); // works too
await whatever(Promise.resolve('maybe'));
// also works

All these invokes work the same, except the last one uses two microtasks.

What is “explicit” to me, when return await value is used in the wild, and without any try/catch guard around, is that not every developer is aware of the fact that async creates already, whenever is needed, that microtask for us, so that the synchronous equivalent would be like Object(Object(value)) … or Promise.resolve(Promise.resolve(value)) …but would anyone actually write that and why?

That being said, if writing return await thing() gives you any feeling you know what you are doing, and everything is fine, just go for it, as the result is exactly the same as not putting await in between.

However, maybe after reading this post, you might reconsider this practice, as it brings zero concrete advantages when used improperly.

In a gist

Web, Mobile, IoT, and all JS things since 00's. Formerly JS engineer at @nokia, @facebook, @twitter.