Clean Code: Taming Asynchronous Code with Async/Await
If you've been writing JavaScript for a while, you've probably run into situations where you need to do something after something else has finished. This is the world of asynchronous programming. In the old days, we had callbacks, which often led to the dreaded "callback hell" - a pyramid of nested functions that was hard to read and even harder to debug.
Then came Promises, which were a big improvement. But the real game-changer was async/await. It's syntactic sugar on top of Promises, and it lets us write asynchronous code that reads like a simple, top-down story.
Let's see it in action.
From Promise Chains to Clean Code
Imagine you need to fetch a user and then their posts. With Promises, you might end up with a chain of .then() calls.
interface User {
id: string;
name: string;
}
interface Post {
id: string;
title: string;
}
interface UserWithPosts extends User {
posts: Post[];
}
// Bad - Nested promises and unclear types
function fetchUserWithPosts(userId: string): Promise<UserWithPosts | null> {
return fetch(`/api/users/${userId}`)
.then(response => response.json())
.then((user: User) => {
return fetch(`/api/users/${user.id}/posts`)
.then(response => response.json())
.then((posts: Post[]) => ({ ...user, posts }));
})
.catch(error => {
console.error('Error:', error);
return null;
});
}
// Good - Async/await and clear types
async function fetchUserWithPosts(userId: string): Promise<UserWithPosts | null> {
try {
const userResponse = await fetch(`/api/users/${userId}`);
if (!userResponse.ok) throw new Error('User not found');
const user: User = await userResponse.json();
const postsResponse = await fetch(`/api/users/${user.id}/posts`);
if (!postsResponse.ok) throw new Error('Posts not found');
const posts: Post[] = await postsResponse.json();
return { ...user, posts };
} catch (error) {
console.error('Error fetching user or posts:', error);
return null;
}
}
The async/await version is much easier to scan. It clearly says: "first, get the user, then get their posts". The try/catch block gives us a standard way to handle errors from either of the fetch calls.
Running Operations in Parallel
What if you need to run multiple asynchronous operations at once? Doing them one by one can be slow. async/await works beautifully with Promise.all to help you run things in parallel.
// Bad: Fetching users one by one is inefficient.
async function fetchAllUserNames(userIds: string[]): Promise<string[]> {
const names: string[] = [];
for (const id of userIds) {
const user = await fetchUserById(id);
if (user) names.push(user.name);
}
return names;
}
// Good: Using Promise.all to fetch all users concurrently.
async function fetchAllUserNames(userIds: string[]): Promise<string[]> {
const userPromises = userIds.map(id => fetchUserById(id));
const users = await Promise.all(userPromises);
return users.filter(user => user !== null).map(user => user!.name);
}
async function fetchUserById(id: string): Promise<User | null> {
// Simulate API call
return { id, name: `User ${id}` };
}
Quick Tips
- Always handle errors. An unhandled promise rejection can crash your application. Always wrap your
awaitcalls in atry/catchblock or make sure the function that contains them has a.catch()attached. asyncall the way. When a function isasync, it always returns aPromise. This means you canawaitit in otherasyncfunctions.- Parallelize with
Promise.all. If you have multiple promises that don't depend on each other, run them in parallel for a nice performance boost.
async/await is one of the most significant improvements to JavaScript in recent years. It cleans up asynchronous code, reduces cognitive load, and helps you write more maintainable applications.