The Problem Async/Await Solves

Before async/await, asynchronous JavaScript was handled with callbacks, which led to deeply nested, hard-to-read code — sometimes called "callback hell". Promises improved this significantly, but chaining many .then() calls still produced verbose, non-linear code. Async/await arrived to make async code read like synchronous code, while remaining non-blocking.

What Async/Await Actually Is

async and await are not a new concurrency model. They are syntactic sugar built on top of Promises. Under the hood, an async function always returns a Promise, and await pauses execution of that async function until the awaited Promise settles.

This means everything you know about Promises still applies — async/await just gives you a cleaner way to write it.

How async Functions Work

async function greet() {
  return "Hello";
}

greet().then(console.log); // "Hello"

Even though greet returns a plain string, wrapping it with async automatically wraps the return value in a resolved Promise. You can always .then() an async function because it always returns a Promise.

How await Works

await can only be used inside an async function (or at the top level of an ES module). It pauses the async function and waits for the Promise to resolve, then returns the resolved value:

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function runSequence() {
  console.log("Start");
  await delay(1000);
  console.log("After 1 second");
  await delay(1000);
  console.log("After 2 seconds");
}

runSequence();

Crucially, await does not block the main thread. It yields control back to the JavaScript event loop while waiting, allowing other code to run. The async function resumes from where it left off once the Promise resolves.

Error Handling with try/catch

With Promises, you use .catch(). With async/await, you use standard try/catch blocks — much more familiar and consistent with synchronous error handling patterns:

async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
    const user = await response.json();
    return user;
  } catch (error) {
    console.error("Failed to fetch user:", error.message);
    return null;
  }
}

Sequential vs. Parallel Execution

This is where many developers introduce unintentional performance issues. Consider this:

// Sequential — each waits for the previous (slow if independent)
const a = await fetchA();
const b = await fetchB();
// Parallel — both start simultaneously (much faster for independent calls)
const [a, b] = await Promise.all([fetchA(), fetchB()]);

If fetchA and fetchB are independent, the sequential version is an unnecessary bottleneck. Use Promise.all when operations don't depend on each other.

Common Pitfalls

  • Forgetting await: Not awaiting a Promise means you get the Promise object, not its resolved value — a silent bug that's easy to miss.
  • Using await in forEach: Array's forEach doesn't handle async callbacks correctly. Use for...of loops or Promise.all with .map().
  • Unhandled rejections: Always wrap async calls in try/catch or handle errors at the call site.
// Wrong - awaits don't work inside forEach
items.forEach(async (item) => { await process(item); });

// Correct - sequential processing
for (const item of items) { await process(item); }

// Correct - parallel processing
await Promise.all(items.map(item => process(item)));

Key Takeaways

  • Async/await is built on Promises — understanding Promises makes async/await clearer.
  • Async functions always return Promises.
  • Await pauses the async function, not the entire thread.
  • Use Promise.all to run independent async operations in parallel.
  • Handle errors with try/catch — and always handle them.

Mastering async/await means understanding the event loop, the Promise it wraps, and the common patterns around sequential vs. parallel execution. Get those right and you'll write async JavaScript that's both clean and performant.