Readable Asynchronous Code with async/await in JavaScript
How async/await syntax replaced Promise chains and made asynchronous JavaScript genuinely readable — with examples.
Writing asynchronous JavaScript used to come with a notorious problem known as “callback hell.” One operation finishes, triggers another, that one triggers another — and as they nested deeper, the code became increasingly painful to both write and read. Promises alleviated most of this, but long .then() chains brought their own kind of complexity. async/await syntax is what actually solved the problem.
A quick recap: the Promise chain
I wrote about Promises back in 2017. A typical scenario — fetching data from an API and processing it — looked something like this with a Promise chain:
fetchUser(userId)
.then(user => fetchPosts(user.id))
.then(posts => filterActive(posts))
.then(activePosts => renderList(activePosts))
.catch(err => console.error(err));
That’s a reasonably readable chain. But the moment error handling gets more nuanced — when certain steps need to handle both success and failure independently, or when you need to make async calls inside a loop — the .then() chain falls apart quickly.
What is async/await?
async/await is syntactic sugar that lets you write Promise-based asynchronous code as if it were synchronous. Under the hood, Promises are still doing all the work; only the surface syntax changes.
Mark a function with async and you can use await inside it. await pauses execution at that line until the Promise resolves, then hands you the result directly.
Here’s the same chain rewritten with async/await:
async function loadUserPosts(userId) {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
const activePosts = filterActive(posts);
renderList(activePosts);
}
The difference is obvious: the code flows top to bottom. Which step consumes which data, and which step depends on which, is right there in front of you.
Error handling
With async/await, error handling is done through a try/catch block — the equivalent of .catch() in a Promise chain:
async function loadUserPosts(userId) {
try {
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
renderList(filterActive(posts));
} catch (err) {
console.error('Failed to load data:', err.message);
}
}
One advantage of this pattern is that synchronous errors — say, a TypeError thrown inside filterActive — are caught by the same catch block. Getting that behavior with Promise chains required extra care.
There is one trap worth knowing about: if you call an async function without await at the call site, any error it throws gets silently swallowed. This is especially easy to miss when calling an async function from inside an event listener:
// Watch out: errors can silently disappear here
button.addEventListener('click', async () => {
await doSomething(); // even if this throws, nothing propagates outward
});
A good habit is to have at least a global .catch() or a window-level error listener as a safety net.
Parallel calls with Promise.all
await makes sequential calls easy, but there’s a subtlety to watch for: each await waits for the previous operation to finish. If you write two independent calls back-to-back with await, the second one unnecessarily waits for the first.
// WRONG: needlessly sequential
const user = await fetchUser(id);
const settings = await fetchSettings(id);
When the two calls are independent, Promise.all is the right tool:
// RIGHT: both start in parallel
const [user, settings] = await Promise.all([
fetchUser(id),
fetchSettings(id)
]);
Promise.all takes an array of Promises, kicks them all off in parallel, and returns an array of results once every one of them has resolved.
Async operations inside loops
Loops were one of the most confusing aspects of asynchronous code in the older callback and Promise styles. async/await simplifies this considerably:
async function processItems(items) {
for (const item of items) {
await processOne(item);
}
}
Each item is fully processed before moving to the next. If sequential processing is what you want, this is all you need. For parallel processing, reach for Promise.all combined with map.
One important gotcha: forEach does not work the way you’d expect with async/await. Array.prototype.forEach does not wait for the Promise returned by its callback — it fires and moves on. If you need await inside a loop, use for...of or a plain for loop.
Browser and Node.js support
async/await arrived with the ES2017 (ES8) standard. As of 2018, the vast majority of modern browsers support it natively. For older browser support, Babel can transpile it. On the Node.js side, it has worked without issues since version 7.6.
Conclusion
The biggest win async/await gave me is not having to burn mental energy just to read asynchronous code. There was always a cognitive overhead to mentally unwrapping a Promise chain; with async/await, the code flows almost like prose. Once I made the switch, I never wanted to go back.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.