Why do I avoid using async/await?

The utility of the async/await functionality in JavaScript is based on the idea that asynchronous code is hard, compared to synchronous code that is easier. This is objectively true, but in most cases I don't think async/await really solves this problem.
Lies and async/await
One of the metrics I use to decide whether to use a pattern is the overall quality of code it brings. For example, a pattern might be clean, concise, or widely used, but if it leads to error-prone code, it's a pattern I might reject. These modes are double-edged swords, and it's easy to shoot yourself in the foot.
First, it is based on a lie.

Async/await makes your asynchronous code look synchronous.

This is its selling point. But for me, that's the problem. It sets the wrong mental model of what happens to your code 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 isn't a problem, but when it is, it's hard to identify because async/await just hides the clues that show it. Take this code as an example.
synchronous code
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 })

There are some performance issues here. We've narrowed the problem down to the processData function. In these three cases, what are your assumptions about the optimization path?
I looked at the first case and found that we are saving two different pieces of data in two different places and then just returning an object. The only place that can be optimized is the save function. There are no other options.
I looked at the second example and had the same idea. The only place that can be optimized is the save function.
Maybe just because I'm so familiar with Promises, but I looked at the third example and I quickly saw an opportunity. I see that we are calling save in succession, 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 })

The same opportunity exists in async/await code, just because we're in the async code mindset, it's hidden in plain sight. Not without hints in the async/await version. The keywords async and await should give us the same intuition as then in the third version. But I bet that for many engineers, it doesn't.
Why not?
This is because we are taught to read async/await code with a synchronous mindset. In the first example of synchronous code, we were unable to parallelize the save call, the same logic (but now incorrect), we come to the second example. Async/await puts our mind in a synchronous mindset, which is the wrong mindset.
Also, if we're going to take advantage of parallelization in the async/await case, we have to use promises anyway.
const processData = async ({ userData, sessionPrefences }) => {
await Promise.all([
save('userData', userData),
save('session',sessionPrefences)
])
return { userData, sessionPrefences }
}

In my opinion, if a particular pattern only works in some common cases, then it must have some very big advantages. If I have to "fall back" to promise mode in some very common cases, then I don't see any advantage of async/await over promises. To me, the cognitive burden of switching between multiple paradigms is not worth it. Promises get the job done in any situation and are as good as async/await every time, if not better.
error handling
Handling errors is critical for asynchronous code. There are a few key places where we have to worry about error handling in synchronous code in JavaScript. This mostly happens when we hand something to a native API, like JSON.parse, or a browser function like window.localstorage.
Let's take our previous example of the save function and apply some error handling. Let's assume that in our synchronous example, save performs an operation that might throw. This makes perfect sense, since if saving to sessionstorage, it might throw during serialization or trying to access sessionstorage. To handle possible errors in synchronous code, we usually use try/catch.
synchronous code
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 value in the catch block. Either way, we must encapsulate any logic that might throw an error in a try block.
async/await
Since async/await lets us "look at async code like we look at synchronization", we also use try/catch blocks. The catch block will even count our reject as an error.
const processData = async ({ userData, sessionPrefences }) => {
try {
await save('userData', userData);
await save('session', sessionPrefences);
return { userData, sessionPrefences }
} catch (err) {
handleErrorSomehow(err)
}
}

Take a look at this, async/await fulfills its promise. It looks almost exactly like the sync version.
Now, there are some schools of programming that rely heavily on try/catches. I feel like they are a mental burden. Whenever there is a try/catch, we now have to worry about not only what the function returns, but also what it throws. Not only do we have branching logic, which adds complexity, but we also have to worry about dealing with two different paradigms at the same time. A function can return a value or throw. Therefore, each function has to deal with both aspects. It's tiring.
The embarrassment of try/catch
One last point about try/catch. In JavaScript, you generally don't see embrace try/catch in many places. Unlike other languages, in other languages, you will often see it, such as Java. The try block in JavaScript immediately excludes this part of the code from many engine optimizations because the code can no longer be broken down into deterministic pieces. In other words, in JavaScript, the same code will run slower when wrapped in a try block than if it is not, even though it has no possibility of throwing.
Promises
Let's see what promises are doing.
const processData = ({ userData, sessionPrefences }) => save('userData', userData)
.then(() => save('session', sessionPrefences))
.then(() => ({ userData, sessionPrefences })
.catch(handleErrorSomehow)

Did you see it?
.catch(handleErrorSomehow)

Yes. That's all there is to it. This does exactly the same thing as the other methods. I find this easier to read than try/catch blocks. What do you think? If only synchronizing code were this simple... wait!
const processData = ({ userData, sessionPrefences }) => Promise. resolve(save('userData', userData))
.then(() => save('session', sessionPrefences))
.then(() => ({ userData, sessionPrefences })
.catch(handleErrorSomehow)

🤯
Well, this has its downsides, but it's also super fun, don't you think? This is just a small hint to get you thinking about what functional style JavaScript would look like if we wanted to. But either way, to accept or not to accept. My purpose is to convince you to use Promises instead of async/await. Rather than promise Promises are overall better than async/await. That would be crazy. 😏
more crucial point
The last point I want to make is. I sometimes come across arguments claiming that async/await prevents the "callback hell" phenomenon that can occur in callbacks and promises.
To be honest, the first time I heard this rhetoric, I thought the person was just confusing and meant to say "callbacks". After all, one of the purposes of promises was to eliminate "callback hell", so I'm confused that people say promises cause callback hell (I mean, it's called callbacks hell after all, not promises hell).
But then I actually saw some code for promises and they looked amazingly like callback hell. I'm confused why anyone would use promises like this. Ultimately, I've come to the conclusion that some people have a very basic misunderstanding of how promises work.
Before I get into this, let me first admit that it's actually impossible to create a pyramid of callback hell with async/await, so it has this advantage. But I've never written a promise flow with more than two levels, there's no need. Of course it is possible, but never necessary.
I find that whenever I see "callback hell" in promise chains, it's because people don't realize that promises act like an infinite flow chart. In other words, a process 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) => {
i 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`)
} }
})

It should really be written 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 this is a bit confusing, let me give you a simpler, but more hypocritical, 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 examples are the same in terms of the number and order in which promises are created (and they are practically out of touch with reality, but this is for academic purposes, so we allow it). However, the latter is more readable than the former.
If you're used to writing promise flows more like the first example, let me give you a nice little trick to get out of that habit.
Everytime you want to write a then or catch in your promise stream, first make sure you return a promise, then go to the outermost promise (if you've been following this rule, there should be only one layer) and add your then or catch. As long as you are returning, your value will bubble up to the outermost promise. This is what you should do with "then".
Remember, you don't necessarily have to return a Promise to use then. Once you're in the context of a promise, any returned value will bubble through it. Promises, numbers, strings, functions, objects, etc.
Promise.resolve(5)
.then((val) => val + 3)
.then((num) => String(num))
.then((str) => `I have ${str} llamas`)
.then((str) => /ll/.test(str))

It's all perfectly legal (albeit a bit unreasonable, but made up examples to make it clear 🤷♂)

This article refers to Why I avoid async/await, with modifications.

Summarize


In my opinion, promises provide a better hint that we're in a mental model of async in simple cases, expressing code at least as cleanly as async/await.
Provides a cleaner alternative for more complex workflows including error handling and parallelization.

Related Articles

Explore More Special Offers

  1. Short Message Service(SMS) & Mail Service

    50,000 email package starts as low as USD 1.99, 120 short messages start at only USD 1.00