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 await calls in a try/catch block or make sure the function that contains them has a .catch() attached.
  • async all the way. When a function is async, it always returns a Promise. This means you can await it in other async functions.
  • 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.