Why Async/Await Changed Everything

Before async/await landed in ES2017, JavaScript developers wrestled with callback hell and promise chains that could spiral into unreadable pyramids of doom. Async/await didn't replace promises — it gave us a cleaner, more human way to write them. If you've ever stared at a .then().then().catch() chain and lost track of what's happening, this guide is for you.

The Basics: What Are async and await?

At its core, async is a keyword you place before a function declaration. It tells JavaScript: "This function will return a promise." The await keyword can only be used inside an async function, and it pauses execution until the awaited promise resolves.

async function brewCoffee() {
  const beans = await fetchBeans();
  const ground = await grindBeans(beans);
  return brew(ground);
}

Each await waits for the previous step to finish before moving on — much like a barista who won't pour milk before the espresso is pulled.

Error Handling with try/catch

One of the biggest wins with async/await is how naturally it integrates with try/catch blocks:

async function getUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error('User not found');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('Failed to fetch user:', error.message);
  }
}

This is far more readable than chaining .catch() handlers on every promise.

Running Promises in Parallel

A common mistake is awaiting promises sequentially when they could run in parallel. Consider this inefficient pattern:

  • Slow: const a = await fetchA(); then const b = await fetchB(); — these run one after the other.
  • Fast: Use Promise.all() to run them simultaneously.
// Parallel — much faster
const [userData, orderData] = await Promise.all([
  fetchUser(id),
  fetchOrders(id)
]);

This is the equivalent of ordering your coffee and pastry at the same time rather than waiting for one before asking for the other.

Common Pitfalls to Avoid

  1. Forgetting to await: If you call an async function without await, you get a pending promise back, not a value.
  2. Using await in loops incorrectly: A for...of loop with await runs sequentially. Use Promise.all(array.map(...))) for parallel iteration.
  3. Unhandled rejections: Always wrap your async logic in try/catch or attach a .catch() to the top-level promise.
  4. Top-level await: Outside of ES modules, await can't be used at the top level. Wrap it in an immediately invoked async function if needed.

async/await with the Fetch API

Here's a complete, real-world example of fetching data from a REST API with proper error handling:

async function fetchArticles(category) {
  try {
    const res = await fetch(`https://api.example.com/articles?cat=${category}`);
    if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
    const articles = await res.json();
    return articles;
  } catch (err) {
    console.error('Could not load articles:', err);
    return [];
  }
}

Key Takeaways

  • Async/await is syntactic sugar over promises — understanding promises first is essential.
  • Always handle errors with try/catch inside async functions.
  • Use Promise.all() when operations are independent to improve performance.
  • Async/await makes asynchronous code read like synchronous code — which is a huge win for maintainability.

Once you get the rhythm of async/await, you'll find it becomes second nature — as natural as reaching for your morning brew before opening your IDE.