JavaScript

Making Sense of Async/Await in JavaScript

javascript-async-await

I still remember the first time I saw a nested callback pyramid in a production codebase. Five levels deep, error handling scattered everywhere, and a comment at the top that read "// sorry." That was 2016. JavaScript has come a long way since then, but I keep seeing developers use async/await like it's just syntactic sugar — slapping await on everything without understanding what's actually happening underneath.

Let's fix that. We'll trace the whole journey from callbacks to async/await, and then dig into the patterns and pitfalls that separate "it works" from "it works well."

The Road to Async/Await

Callbacks: Where It Started

JavaScript is single-threaded. It can only do one thing at a time. So when you need to do something that takes a while — fetch data, read a file, wait for a timer — you hand off a function to be called when it's done. That's a callback.

fetchUser(userId, function(err, user) {
  if (err) return handleError(err);
  fetchPosts(user.id, function(err, posts) {
    if (err) return handleError(err);
    fetchComments(posts[0].id, function(err, comments) {
      if (err) return handleError(err);
      console.log(comments);
    });
  });
});

Three levels deep and it's already painful to read. Each step depends on the previous one. Error handling is duplicated. Logic is buried in indentation. This is "callback hell," and it's not just ugly — it's genuinely hard to debug and maintain.

Promises: A Step Forward

Promises gave us a way to chain asynchronous operations instead of nesting them:

fetchUser(userId)
  .then(user => fetchPosts(user.id))
  .then(posts => fetchComments(posts[0].id))
  .then(comments => console.log(comments))
  .catch(err => handleError(err));

Much better. Flat chain, single error handler at the end, clear data flow. But promises have their own quirks — try branching logic inside a .then() chain, or accessing a value from two steps back. It gets messy fast.

Async/Await: Write Async Code That Reads Like Sync

Async/await landed in ES2017 and changed how we write asynchronous JavaScript. It's built on top of promises — not a replacement for them — but it lets you write code that looks sequential:

async function loadComments(userId) {
  const user = await fetchUser(userId);
  const posts = await fetchPosts(user.id);
  const comments = await fetchComments(posts[0].id);
  console.log(comments);
}

Same logic as the callback version. Same logic as the promise chain. But it reads like synchronous code. Each line waits for the previous one to finish. Variables are in scope. No nesting, no chaining.

That async keyword before the function does two things: it guarantees the function returns a promise, and it lets you use await inside it. The await keyword pauses execution of that function until the promise resolves — but it doesn't block the main thread. Other code keeps running.

Error Handling That Actually Works

This is the part most people get wrong. With promises, you had .catch(). With async/await, you use try/catch:

async function loadComments(userId) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);
    return comments;
  } catch (err) {
    console.error('Failed to load comments:', err.message);
    return [];
  }
}

Simple enough. But here's what bugs me: wrapping your entire function body in a try/catch is often too broad. If fetchPosts fails, you might want to handle that differently than if fetchComments fails. One approach I like is handling errors per-operation:

async function loadUserDashboard(userId) {
  const user = await fetchUser(userId); // let this throw if it fails

  let posts = [];
  try {
    posts = await fetchPosts(user.id);
  } catch (err) {
    console.warn('Posts unavailable, showing empty state');
  }

  let notifications = [];
  try {
    notifications = await fetchNotifications(user.id);
  } catch (err) {
    console.warn('Notifications unavailable');
  }

  return { user, posts, notifications };
}

The user fetch is critical — if that fails, the whole thing should fail. But posts and notifications? We can degrade gracefully. This kind of nuanced error handling is much harder to express with promise chains.

Tip: If you forget to handle a rejected promise (no .catch() and no try/catch), you'll get an UnhandledPromiseRejection. In Node.js, this will crash your process by default. Always handle your errors — even if it's just logging them.

Parallel Execution: Stop Awaiting in Sequence

This is the single most common performance mistake with async/await. Look at this code:

async function loadDashboard() {
  const users = await fetchUsers();       // 500ms
  const products = await fetchProducts(); // 400ms
  const orders = await fetchOrders();     // 600ms
  // Total: 1500ms 😬
}

These three requests don't depend on each other. There's no reason to wait for one before starting the next. But await pauses execution, so they run sequentially. Total time: the sum of all three.

Fix it with Promise.all():

async function loadDashboard() {
  const [users, products, orders] = await Promise.all([
    fetchUsers(),       // starts immediately
    fetchProducts(),    // starts immediately
    fetchOrders(),      // starts immediately
  ]);
  // Total: ~600ms (slowest one wins) 🎉
}

All three requests fire simultaneously. We wait for the slowest one. In this example, you just saved 900 milliseconds. In a real app with more API calls, the difference can be enormous.

Promise.allSettled() — When You Don't Want One Failure to Kill Everything

Promise.all() rejects the moment any promise in the array rejects. Sometimes that's not what you want. Promise.allSettled() waits for all promises to complete, whether they succeed or fail:

async function loadWidgets() {
  const results = await Promise.allSettled([
    fetchWeather(),
    fetchNews(),
    fetchStockPrices(),
  ]);

  results.forEach((result, i) => {
    if (result.status === 'fulfilled') {
      console.log(`Widget ${i}: `, result.value);
    } else {
      console.warn(`Widget ${i} failed: `, result.reason);
    }
  });
}

Perfect for dashboards where individual widgets can fail independently. I use this pattern constantly.

Common Pitfalls

Pitfall #1: Await in Loops

This is the sequential-when-it-should-be-parallel problem in disguise:

// ❌ Bad — sequential, slow
async function processItems(items) {
  for (const item of items) {
    await processItem(item); // each waits for the previous
  }
}

// ✅ Better — parallel
async function processItems(items) {
  await Promise.all(items.map(item => processItem(item)));
}

With 100 items, the sequential version takes 100× longer. The parallel version runs them all at once.

But wait — sometimes you actually need sequential execution. Maybe each operation depends on the last, or you're rate-limited and can't fire 100 requests simultaneously. In those cases, the for...of loop with await is correct. Know the difference.

Pitfall #2: Forgetting to Await

async function saveAndNotify(data) {
  save(data);           // ← forgot await! This returns a promise
  await notify(data);   // This might run before save() finishes
}

Without await, save(data) fires and the function immediately moves on to notify(data). If save fails, the error is unhandled. Linters like ESLint have rules (no-floating-promises with TypeScript, require-await) that catch this.

Pitfall #3: Async Functions Always Return Promises

async function getNumber() {
  return 42;
}

const result = getNumber();
console.log(result); // Promise { 42 }, not 42!

You can't extract the value from a promise synchronously. You must await it or use .then(). This trips up developers who mix async and sync code.

Tip: Use Promise.race() to implement timeouts. Wrap your fetch call and a timeout promise in Promise.race() — whichever resolves first wins. This prevents your app from hanging indefinitely on a slow network request.

Pitfall #4: Error Swallowing in .forEach()

This one's subtle. Array.forEach() doesn't wait for async callbacks:

// ❌ Broken — errors vanish, nothing actually awaited
items.forEach(async (item) => {
  await processItem(item);
});
console.log('Done!'); // Runs immediately, before any items are processed

forEach fires all the callbacks and moves on. It doesn't care about the promises they return. Use for...of or Promise.all(items.map(...)) instead.

Real-World Patterns

Retry Logic

async function fetchWithRetry(url, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return await response.json();
    } catch (err) {
      console.warn(`Attempt ${attempt} failed: ${err.message}`);
      if (attempt === maxRetries) throw err;
      await new Promise(r => setTimeout(r, 1000 * attempt)); // backoff
    }
  }
}

Exponential backoff, clear attempt logging, throws on final failure. This pattern has saved me in production more times than I can count.

Sequential with Early Exit

async function findFirstAvailable(urls) {
  for (const url of urls) {
    try {
      const response = await fetch(url);
      if (response.ok) return url; // found one that works
    } catch {
      continue; // try the next one
    }
  }
  throw new Error('No URLs available');
}

Check a list of endpoints and return the first one that responds. Sequential here is intentional — we want to stop as soon as we find a working one.

Share Beautiful Code Snippets

Turn your JavaScript code into stunning, shareable images for documentation and social media.

Try Code to Image →

When Not to Use Async/Await

Async/await isn't always the answer. For event streams, use observables or event listeners. For simple one-off transformations, a raw promise chain might be cleaner. And if you're writing a library that needs to support both callback and promise styles, you might need to work with raw promises.

But for 90% of the async code you write — API calls, database queries, file operations — async/await is the right tool. It's readable, debuggable (stack traces actually work properly now), and the error handling patterns are familiar to anyone who's written try/catch before.

The key takeaway: async/await makes async code look synchronous, but it doesn't make it synchronous. Keep thinking about what runs in parallel, what runs in sequence, and where errors can occur. The syntax is simple — the thinking behind it still matters.

Sachin Bhanushali
Written by

Sachin Bhanushali

Full-stack developer and creator of HTMLtoImages. Building free, privacy-first developer tools that run entirely in your browser.