Skip to main content

Command Palette

Search for a command to run...

Async/Await in JavaScript: Writing Cleaner Asynchronous Code

Updated
4 min readView as Markdown
Async/Await in JavaScript: Writing Cleaner Asynchronous Code

If you followed the evolution of asynchronous JavaScript, you know it started with Callbacks, which quickly devolved into the messy, deeply nested "Pyramid of Doom."

To fix this, JavaScript introduced Promises. Promises were a massive step forward. Instead of passing functions into other functions, you could chain .then() methods together to handle data once it arrived.

But even Promises had a readability problem. A long chain of .then() and .catch() blocks still felt a bit disjointed. It didn't read like standard, top-to-bottom JavaScript.

In 2017, JavaScript released the ultimate solution: Async/Await.

It didn't replace Promises; it simply provided a beautiful new syntax for them. Let's look at how this "syntactic sugar" completely transformed how we write asynchronous code.

The Goal: Making Async Look Sync

The primary reason async/await was introduced was to allow developers to write asynchronous code that looks and behaves like synchronous code.

When you read a normal script, it executes line 1, then line 2, then line 3. Your brain easily tracks the flow. async/await brings that same top-to-bottom readability to tasks that take time to complete, like fetching API data or reading files.

1. The async Keyword: The Promise Maker

To use this new syntax, you first have to declare a function as asynchronous by placing the async keyword in front of it.

async function fetchUserProfile() {
  return "Alex";
}

When you add async to a function, JavaScript does something magical behind the scenes: it guarantees that the function will return a Promise. Even though we are just returning a simple string "Alex" in the code above, the engine automatically wraps that string in a resolved Promise.

2. The await Keyword: The Pause Button

The real power unlocks when you use await inside your async function.

The await keyword acts like a pause button. When JavaScript hits an await, it literally stops executing that specific function until the Promise sitting next to it finishes resolving.

async function getDashboardData() {
  console.log("1. Requesting data...");
  
  // The function pauses here until the data arrives
  const response = await fetch('https://api.example.com/data'); 
  
  // This line won't run until the line above is completely finished
  console.log("2. Data received!", response); 
}

It is crucial to note that while this function is paused, the rest of your application keeps running perfectly fine. It doesn't freeze the browser; it just pauses the local execution of getDashboardData.

The Showdown: Promises vs. Async/Await

To truly appreciate the readability improvement, you have to see them side-by-side.

Let's say we need to fetch a user, and then use that user's ID to fetch their posts.

The Old Way (Promise Chaining):

function getUserPosts() {
  fetch('https://api.example.com/user')
    .then(response => response.json())
    .then(user => {
      return fetch(`https://api.example.com/posts/${user.id}`);
    })
    .then(response => response.json())
    .then(posts => {
      console.log("User posts:", posts);
    });
}

Notice all the callbacks, the returning of new fetch calls, and the nested .then() structures.

The Modern Way (Async/Await):

async function getUserPosts() {
  const userResponse = await fetch('https://api.example.com/user');
  const user = await userResponse.json();
  
  const postResponse = await fetch(`https://api.example.com/posts/${user.id}`);
  const posts = await postResponse.json();
  
  console.log("User posts:", posts);
}

Look at how clean that is! No nesting, no callbacks. Just flat, step-by-step logic that reads exactly like a book.

Catching the Crash: Error Handling

With Promise chains, you handled errors by tacking a .catch() onto the very end of the chain. Because async/await behaves like standard synchronous code, we can go back to using our trusty try...catch blocks.

This is a huge advantage because it allows you to handle both synchronous and asynchronous errors in the exact same place.

async function fetchWithSafety() {
  try {
    // Risky async code
    const response = await fetch('https://api.broken-link.com');
    const data = await response.json();
    
    // Risky sync code
    console.log(data.missingProperty.toLowerCase()); 
    
  } catch (error) {
    // If the fetch fails, OR if the toLowerCase() fails, it all gets caught here!
    console.error("Something went wrong:", error.message);
  }
}