2 min read
On this page

Async & Await

Syntactic Sugar Over Promises

async/await makes asynchronous code read like synchronous code. It does not replace promises — it is built on top of them.

// With promises
function getUser(id) {
  return fetch(`/api/users/${id}`)
    .then(response => response.json());
}

// With async/await
async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

async Functions Always Return a Promise

async function greet() { return "Hello"; }
greet().then(msg => console.log(msg));  // "Hello"

async function failing() { throw new Error("Broke"); }
failing().catch(e => console.error(e.message));  // "Broke"

await Pauses Until the Promise Settles

await can only be used inside an async function (or at the top level of a module). It pauses that function until the promise fulfills or rejects. The rest of the program continues.

async function loadDashboard(userId) {
  const user = await fetchUser(userId);
  const orders = await fetchOrders(user.id);
  console.log(`${user.name} has ${orders.length} orders`);
}

Error Handling with try/catch

async function loadProfile(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const user = await response.json();
    displayProfile(user);
  } catch (error) {
    console.error("Failed:", error.message);
    displayErrorMessage("Could not load profile.");
  }
}

Granular Error Handling

async function checkout(cartId) {
  let cart;
  try { cart = await fetchCart(cartId); }
  catch { showError("Could not load cart"); return; }

  let payment;
  try { payment = await processPayment(cart); }
  catch { showError("Payment failed"); return; }

  try { await sendConfirmationEmail(payment.orderId); }
  catch (e) { console.warn("Email failed:", e.message); }  // non-critical

  showSuccess(`Order ${payment.orderId} confirmed`);
}

Parallel Async with Promise.all

The most common performance mistake: running independent operations sequentially.

// Bad: sequential — 1500ms total
const user = await fetchUser(id);        // 500ms
const orders = await fetchOrders(id);    // 500ms
const settings = await fetchSettings(id); // 500ms

// Good: parallel — 500ms total
const [user, orders, settings] = await Promise.all([
  fetchUser(id), fetchOrders(id), fetchSettings(id)
]);

Mixed Sequential & Parallel

async function loadOrderPage(userId) {
  const user = await fetchUser(userId);  // need user first
  const [orders, prefs] = await Promise.all([  // these are independent
    fetchOrders(user.id), fetchPreferences(user.id)
  ]);
  return { user, orders, prefs };
}

Async Iteration

// Sequential: one at a time
async function processSequentially(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    process(await response.json());
  }
}

// Parallel: all at once
async function processInParallel(urls) {
  return Promise.all(urls.map(async url => {
    const response = await fetch(url);
    return response.json();
  }));
}

Top-Level Await

In ES modules, await works outside async functions.

// config.js (ES module)
const response = await fetch("/api/config");
export const config = await response.json();

Any module that imports it waits for the promise to resolve.

Async/Await vs Raw Promises

Use async/await for sequential steps with branching. Use raw promises for combinators and utilities.

// Raw promises: cleaner for composition
function fetchWithTimeout(url, ms) {
  return Promise.race([
    fetch(url),
    new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), ms))
  ]);
}

// async/await: cleaner for sequential logic with retry
async function retryFetch(url, attempts) {
  for (let i = 0; i < attempts; i++) {
    try {
      const response = await fetch(url);
      if (response.ok) return response;
    } catch (e) { if (i === attempts - 1) throw e; }
  }
}

Real-World Pattern: Retry with Backoff

async function fetchWithRetry(url, { maxRetries = 3, baseDelay = 1000 } = {}) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url);
      if (response.ok) return response;
      if (response.status >= 400 && response.status < 500) {
        throw new Error(`Client error: ${response.status}`);
      }
      throw new Error(`Server error: ${response.status}`);
    } catch (error) {
      if (attempt === maxRetries) throw error;
      const delay = baseDelay * Math.pow(2, attempt);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

Common Pitfalls

Sequential await for independent operations. Use Promise.all when operations do not depend on each other.

Using await inside forEach. forEach does not await async callbacks. The loop finishes immediately.

// Bug: forEach fires all at once, doesn't wait
urls.forEach(async url => { await fetch(url); });

// Fix: for...of for sequential
for (const url of urls) { await fetch(url); }

// Fix: Promise.all for parallel
await Promise.all(urls.map(async url => fetch(url)));

Swallowing errors in try/catch. Catching an error and doing nothing hides bugs. At minimum, log it.

Unnecessary async. If a function just returns a promise without using await, drop the async keyword.

Key Takeaways

  • async/await is syntactic sugar over promises. async functions always return a promise.
  • Use try/catch for error handling. Granular blocks let you handle different failures differently.
  • Use await Promise.all([...]) for independent parallel operations. Sequential await is for dependent steps.
  • forEach does not work with await. Use for...of or Promise.all with map.
  • Top-level await works in ES modules for initialization.
  • Choose async/await for readability, raw promises for composition.