EDIT: I recently turned this article into a talk I gave at UtahJS 2023. It’s also partly a response to some of the reactions I got to this article. In many ways, I feel like I did a bit of a better job with my arguments there than here. Let me know what you think.
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.
Lies, damned lies, and async/await
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 isn’t worth it. Promises get the job done in every scenario and do as good or better than async/await just about every time.
Error handling
Handling errors is vital for asynchronous code. There are a few key places we have to worry about error handling with synchronous code in JavaScript. it’s mostly when we hand off something to a native API like JSON.parse
, or a browser feature like window.localstorage
.
Let’s take a look at our previous example with the save
function and apply some error handling. Let’s assume that in our synchronous example, save
performs an operation that could throw. This is very plausible because if this saves to sessionstorage
, it could throw during serialization (JSON.parse
) or during the attempt to access sessionstorage
. To handle possible errors from synchronous code, we typically use try/catch.
Fully Synchronous
const processData = ({ userData, sessionPrefences }) => {
try {
save('userData', userData);
save('session', sessionPrefences);
return { userData, sessionPrefences }
} catch (err) {
handleErrorSomehow(err)
}
}
Depending on the strategy, we might rethrow the error, or return some default in the catch
block. Either way, we must wrap any logic that might throw an error in the try
block.
Async/Await
Since async/await lets us “think of async code like it is sync” we also use a try/catch block. The catch block will even normalize our rejecting into an error for us.
const processData = async ({ userData, sessionPrefences }) => {
try {
await save('userData', userData);
await save('session', sessionPrefences);
return { userData, sessionPrefences }
} catch (err) {
handleErrorSomehow(err)
}
}
Look at that, async/await living up to its promise. It looks almost identical to the synchronous version.
Now, there are some schools of programming that lean heavily into try/catches. Me, I find them mentally taxing. Whenever there is a try/catch, we now have to worry not only about what the function returns, but what it throws. We not only have branching logic, which increases complexity but also have to worry about dealing with two different paradigms. A function may return a value, or it may throw. Throwing bubbles if it is not caught. Returns do not. So both have to be dealt with for every function. It’s exhausting.
The deoptimized path
One final point on try/catch. There is a reason you don’t generally see patterns embracing the try/catch paradigm many places in JavaScript. Because unlike in other languages where you do see it more often, such as Java, a try block in JavaScript immediately opts that section of code out of many engine optimizations as the code can no longer be broken down into determinative pieces. In other words, the same code will run slower in JavaScript when wrapped in a try block than when not, even if there is no chance of it throwing.
To be honest, this is probably never going to matter, and maybe it’s not even worth bringing up, but it’s kind of interesting, and it supports my priors, so there it is 😙.
Let’s visit our Promise friend and see what she’s up to.
Promise
const processData = ({ userData, sessionPrefences }) => save('userData', userData)
.then(() => save('session', sessionPrefences))
.then(() => ({ userData, sessionPrefences })
.catch(handleErrorSomehow)
Did you see it? Go back and look, it’s easy to miss after coming from the verboseness of the try/catch syntax.
.catch(handleErrorSomehow)
Yep. That’s all there is to it. This does exactly the same thing as the others. I find this to be greatly easier to read than try/catch blocks. How about you? If only it were this simple for synchronous code that thro… Hang on a tick!
const processData = ({ userData, sessionPrefences }) => Promise.resolve(save('userData', userData))
.then(() => save('session', sessionPrefences))
.then(() => ({ userData, sessionPrefences })
.catch(handleErrorSomehow)
🤯
Ok, there are drawbacks to this, but it’s also super interesting, don’t you think? It’s just a tiny hint to get you thinking about what functionally style JavaScript could look like if we wanted. But whatever, take it or leave it. My intention is to persuade you to use Promises over async/await. Not PROMISIFY ALL THE THINGS. That would be crazy. 😏
One more thing…
One last point I want to bring up. I sometimes come across arguments that claim that async/await prevent “callback hell” that can occur with callbacks and promises.
To be honest, the first time I heard this argument with respect to promises, I thought the person was just confused and meant to say “callbacks.” After all, one of the promises (😏) of promises, when they were brand new, was that they would eliminate the problem of “callback hell” so I was confused that people were saying that promises cause callback hell (I mean, it’s called callback hell, not promise hell after all).
But then I actually saw some promise code that looked astonishingly like callback hell. I was baffled that anyone would use promises this way. Eventually, I concluded that some people have a very basic misunderstanding of how promises work.
Before I get into that, let me first acknowledge that it is in fact not possible to create the pyramid of doom with async/await that callback hell is associated with, so it has that going for it. BUT I have never had to write a promise flow that went any deeper than two levels. It’s just not necessary. Possible, sure. But never necessary.
I’ve found that whenever I’ve seen “callback hell” in a promise chain, it’s because folks didn’t realize promises act like an infinitely deep flatMap. In other words, a flow like this:
const id = 5
const lotsOAsync = () => fetch('/big-o-list')
.then((result) => {
if (result.ok) {
return result.json().then((list) => {
const {url: itemURL } = data.items.find((item) => item.id === id)
return fetch(itemURL).then((result) => {
if (result.ok) {
return result.json().then((data) => data.name)
} else {
throw new Error(`Couldn't fetch ${itemURL}`)
}
})
})
} else {
throw new Error(`Couldn't fetch big-o-list`)
}
})
Really ought to be written more like this:
const id = 5
const lotsOAsync = () => fetch('/big-o-list')
.then((result) => result.ok ? result.json() : Promise.reject(`Couldn't fetch big-o-list`))
.then(({ items }) => items.find((item) => item.id === id))
.then(({url}) => fetch(url))
.then((result) => result.ok ? result.json() : Promise.reject(`Couldn't fetch ${result.request.url}`))
.then((data) => data.name)
If that is a bit confusing, let me give you a simpler, but more contrived example.
Callback Hell 🔥
Promise.resolve(
Promise.resolve(
Promise.resolve(
Promise.resolve(
Promise.resolve(
Promise.resolve(5)
)
)
)
)
).then((val) => console.log(val))
Promise Heaven 👼
Promise.resolve()
.then(() => Promise.resolve())
.then(() => Promise.resolve())
.then(() => Promise.resolve())
.then(() => Promise.resolve())
.then(() => Promise.resolve(5))
.then((val) => console.log(val))
Both of these examples are identical in terms of the number and order of promises they create (as well as their total banality and uselessness, but this is for academic purposes, so we’ll allow it). However, one is radically more readable than the other.
If you are used to writing promise flows that resemble the first example more than the second, let me offer you a nice little trick to get out of that habit.
Every time you want to write a then
or a catch
in your promise flow, first make sure you return the promise instead, then go to the outermost promise (if you’ve followed the rule to this point, that should be only one level up) and add your then
or catch
there. As long as you are returning, your value will bubble out to the outermost promise. That’s where you should do your thenning.
Keep in mind that you don’t have to return a Promise
to use then
. Once you are in the context of a promise, any returned value will bubble through it. Promise
, number
, string
, function
, object
, whatever.
Promise.resolve(5)
.then((val) => val + 3)
.then((num) => String(num))
.then((str) => `I have ${str} llamas`)
.then((str) => /ll/.test(str))
This is all perfectly legitimate (if a little silly and inefficient, but hey, what are contrived examples for? 🤷♂)
TL;DR; But I did scroll.
In my view, promises
- Give better cues that we are in an asynchronous mental model
- In simple cases express the code at least as cleanly as async/await
- Provides a much cleaner option for more complex workflows that include error handling and parallelization.
¹ The issues with switch
is a conversation for another post, but the JavaScript pattern matching proposal has as one of its primary motivations to do away with the switch footguns.
² We can tell that one call to save
is not dependent on another because it doesn’t return anything. Or at least we don’t care about what it returns since we aren’t using it. If there was a dependency we would see that in the form of using the return value or something we derive from it being passed into the call to save
. Now there could still be a system requirement that these two saves be called in serial — for instance, if user
had a property being used as a foreign key to associate the sessionPreferences
to it, but doing that at this layer is a sign of much bigger problems than we have to deal with in this wee article.