This article was originally published at https://maximorlov.com/async-await-better-than-chaining-promises/
If you're already familiar with promises and chaining them you might be wondering โ why learn a new syntax? Why use async/await when you can use promises to accomplish the same thing? ๐คจ
And yet developers love async/await and are saying how it has helped them write better code. Makes you wonder if they know something you don't...
Only if you had someone to show you where promises fall short. It could give you the insight you've been missing all this time! ๐ก
Well, you do!
Grab a seat and read on as I point out the shortcomings of Promise.then() syntax and how async/await helps you write better asynchronous JavaScript code. ๐ฏ
Reason #1 - Confusing order of execution
One of the first things we're taught in programming is that code is executed from top to bottom. We tell machines what to do by writing code as a sequence of chronological steps โ first do this, then do that, and finally run that.
By definition, asynchronous code doesn't run linearly and it contradicts how we've learned to read and reason about code.
While promises are a vast improvement over callbacks (and they power async/await under the hood), they can give you the impression that code runs in a weird order.
Here is an example with print statements before, inside, and after the promise chain:
console.log('Start'); // 1
insertComment(comment)
.then((commentId) => {
console.log('Comment ID is', commentId); // 3
});
console.log('End'); // 2
This prints the following:
Start
End
Comment ID is e03c2055-2309-494f-b37e-f153bc673445
This is unintuitive because it looks like the program is jumping from end to middle, when in fact code always executes from top to bottom. What happens is that the execution of asynchronous code is deferred to a future point in time with the help of the event loop.
When we take the same code and refactor it to async/await, it not only runs from top to bottom but also reads from top to bottom.
console.log('Start'); // 1
const commentId = await insertComment(comment);
console.log('Comment ID is', commentId); // 2
console.log('End'); // 3
Which unsurprisingly prints:
Start
Comment ID is e03c2055-2309-494f-b37e-f153bc673445
End
Async/await allows you to write asynchronous code that reads like synchronous code.
And that's powerful.
Reason #2 - Reusing values inside promise chains
Another issue with promise chains is when you want to reuse values at later steps in the chain. Each .then()
method creates a separate function scope and, therefore, prevents accessing its variables from later steps in the chain.
insertComment(comment)
.then((commentId) => {
return insertHashtag(hashtag, commentId);
})
.then((hashtagId) => {
// how do we use commentId in here?
console.log('Comment ID is', commentId);
});
In practice, this issue is often worse because you have longer promise chains and you want to access several values at different steps in the chain.
Promise.then() solutions and their shortcomings
You could solve this in a few different ways with Promise.then() syntax, but let me show you why they're not ideal.
A solution would be to initialize the variables you need outside of the promise chain. This allows you to reference them at later steps in the chain because the variables are declared in a shared outer scope.
// Initialize variable in outer scope so we can reference it
// from a later step in the promise chain
let commentId;
insertComment(comment)
.then((newCommentId) => {
// Need to come up with a new name to avoid name clash ๐
โโ๏ธ
commentId = newCommentId;
return insertHashtag(hashtag, commentId);
})
.then((hashtagId) => {
console.log('Comment ID is', commentId);
});
The problem with this is that you have to come up with different names for each variable to avoid name clashes, and we all know that naming things is hard in programming.
Moreover, with longer promise chains it's hard to keep track of where the variables were assigned and what values they hold. I've had to debug some long promise chains in production that were using this solution and I can tell you it was not fun.
Another solution is to add a nested promise chain and resolve with the value needed in the next step.
insertComment(comment)
.then((commentId) => {
return insertHashtag(hashtag, commentId)
// Add a nested promise chain and resolve with the value
// needed in the next step
.then((hashtagId) => {
return commentId; // this nesting though ๐ต
});
})
.then((commentId) => {
console.log('Comment ID is', commentId);
});
However, nesting promise chains goes against why you would chain promises in the first place โ which is to create a flat async structure. Everyone hated callback hell (a.k.a. pyramid of doom) from earlier days of asynchronous JavaScript, and nobody wants to work with that mess again.
You also need to make sure to return nested promises otherwise rejections won't bubble up and your Node.js server will quit unexpectedly.
Lastly, you could use Promise.all
to pass an array of values to subsequent steps in the chain.
insertComment(comment)
.then((commentId) => {
// Need to make sure to keep this array in sync with..
return Promise.all([
commentId,
insertHashtag(hashtag, commentId)
]);
})
.then(([commentId, hashtagId]) => { // <-- ..this array
console.log('Comment ID is', commentId);
});
This is in my opinion the "least worse" solution, as it avoids name clashes or nesting promises. Before async/await was a thing, this would be my go-to approach.
The downside is that you have to keep both arrays in sync. When you change something in one array it's easy to forget about the other. Adding Promise.all in your promise chains quickly becomes tedious and it affects the readability of your code.
Using async/await
Let's look at how you'd write this with async/await.
// โจ
const commentId = await insertComment(comment);
await insertHashtag(hashtag, commentId);
console.log('Comment ID is', commentId);
There's not much to explain really, it's concise and clear. No separate function scopes to worry about and no weird workarounds. Amazing!
Reason #3 - Conditional asynchronous tasks
Another reason to use async/await over Promise.then() syntax is when you have async tasks inside an if statement. It's tricky to handle this nicely with Promise.then() and you'll soon find yourself in a "promise hell".
insertComment(comment)
.then((commentId) => {
if (hashtag) {
// Ugh this nesting starts to look like callback hell...
return insertHashtag(hashtag, commentId)
.then((hashtagId) => {
// If we don't resolve with commentId the next value
// in the chain might be hashtagId and we'll have
// to add a check for that
return commentId;
});
}
return commentId;
})
.then((commentId) => {
console.log('Comment ID is', commentId);
});
You can't get around the nesting problem because if you resolve with a different value (hashtagId) you won't know what value you're getting in the next step (hashtagId or commentId?). In some situations you can programmatically check which value you're working with, but often you can't.
With async/await this is a non-issue. You just put the entire logic inside an if statement and that's it.
// ๐
const commentId = await insertComment(comment);
if (hashtag) {
await insertHashtag(hashtag, commentId);
}
console.log('Comment ID is', commentId);
This code is straightforward and runs as expected.
By now you're familiar with the scenarios where Promise.then() syntax falls short and how async/await empowers you to write clean asynchronous code. Use this knowledge to write better JavaScript code and progress in your development career.
Master Asynchronous JavaScript ๐
Learn how to write modern and easy-to-read asynchronous code with a FREE 5-day email course.
Through visual graphics you will learn how to decompose async code into individual parts and put them back together using a modern async/await approach. Moreover, with 30+ real-world exercises you'll transform knowledge into a practical skill that will make you a better developer.
๐ Get Lesson 1 now
Top comments (0)