Whatever your stance on async/await, I’d like to pitch to you on why, in my experience, async/await tends to make code more complicated, not less.
The utility of the async/await feature in JavaScript rests on the idea that asynchronous code is hard and synchronous code, by comparison, is easier. This is objectively true, but I don’t think async/await actually solves that problem in most cases.
One of the metrics I use for determining whether I want to use a given pattern is the incentives it promotes. For instance, a pattern may be clean, terse, or widely used, but if it incentivizes brittle or error-prone code, it is a pattern I am likely to reject. Often these are called footguns, because they are so easy to shoot oneself in the foot with. Like almost everything, it’s not a binary whether something is a footgun or not. It all exists on a spectrum, so how much of a footgun is often a better question than whether it is one or not.
On the footgun scale of 0 to with
, async/await falls somewhere in the neighborhood of switch
¹, so I have a few issues with it. For one, it is built on a lie.
Async/await lets you write asynchronous code like it is synchronous.
This is the common selling point. But for me, that’s the problem. It sets your mental model for what is happening with the code on the wrong foot from the start. Synchronous code may be easier to deal with than asynchronous code, but synchronous code is not asynchronous code. They have very different properties.
Many times this is not a problem, but when it is, it’s very hard to recognize, because the async/await hides exactly the cues that show it. Take this code as an example.
Fully Synchronous
const processData = ({ userData, sessionPrefences }) => {
save('userData', userData);
save('session', sessionPrefences);
return { userData, sessionPrefences }
}
Async/Await
const processData = async ({ userData, sessionPrefences }) => {
await save('userData', userData);
await save('session', sessionPrefences);
return { userData, sessionPrefences }
}
Promises
const processData = ({ userData, sessionPrefences }) => save('userData', userData)
.then(() => save('session', sessionPrefences))
.then(() => ({ userData, sessionPrefences })
Imagine there was some performance issue. And let’s imagine we’ve narrowed the problem to the processData
function. In each of these three scenarios, what would your assumption be about possible avenues of optimization?
I look at the first one and see we are saving two different pieces of data in two different places, and then just returning an object. The only place to optimize is the save function. There aren’t any other options.
I look at the second example and think the same thing. The only place to optimize is the save function.
Now maybe it’s just my familiarity with Promises, but I look at the third example and I can quickly see an opportunity. I see that we are calling save
serially even though one does not depend on the other.² We can parallelize our two save
calls.
const processData = ({ userData, sessionPrefences }) => Promise.all([
save('userData', userData),
save('session', sessionPrefences)
])
.then(() => ({ userData, sessionPrefences })
Now, the same opportunity exists with the async/await code, it’s just hidden right in plain view because we are in an asynchronous code mindset. It’s not like there aren’t cues in the async/await version. The keywords async
and await
should give us the same intuition that the then
does in the third. But I’ll wager for many engineers it doesn’t.
Why not?
It’s because we are taught to read async/await code in a synchronous mindset. We can’t parallelize the save
calls in that first fully synchronous example, and that same — but now incorrect — logic follows us to the second example. Async/await puts our minds in a synchronous mental model for asynchronous code, and that is the wrong mental model to be in.
Furthermore, if we are to take advantage of parallelization in the async/await example, we must use promises anyway.
const processData = async ({ userData, sessionPrefences }) => {
await Promise.all([
save('userData', userData),
save('session',sessionPrefences)
])
return { userData, sessionPrefences }
}
In my view, there’s got to be some really big advantages to a given pattern if it only works in a subset of common situations. I don’t see that advantage with async/await over promises if I have to “fall back” to the promise paradigm in some pretty common situations. For me, the cognitive load of switching between multiple paradigms just