Home JavaScript async and await in JavaScript — How, Why, and When to Use Them

async and await in JavaScript — How, Why, and When to Use Them

In Plain English 🔥
Imagine you order a pizza and sit by the phone waiting for it — you can't do anything else until it arrives. That's old-school synchronous code. async and await are like placing the order, going to watch TV, and trusting the doorbell will ring when the pizza's ready. Your program keeps doing useful work while it waits for slow things like network requests or file reads, and 'await' is just the doorbell — it tells JavaScript: 'pause here until this one thing is done, then carry on.'
⚡ Quick Answer
Imagine you order a pizza and sit by the phone waiting for it — you can't do anything else until it arrives. That's old-school synchronous code. async and await are like placing the order, going to watch TV, and trusting the doorbell will ring when the pizza's ready. Your program keeps doing useful work while it waits for slow things like network requests or file reads, and 'await' is just the doorbell — it tells JavaScript: 'pause here until this one thing is done, then carry on.'

Every real app talks to something slow — a database, an API, a file system. If JavaScript stopped and stared at the wall waiting for each of those operations to finish, your UI would freeze, your server would stall, and your users would leave. Asynchronous programming is the reason JavaScript can handle thousands of requests without breaking a sweat, and async/await is the cleanest way to write that kind of code today.

Before async/await landed in ES2017, developers handled async operations with callbacks, then Promises. Both work, but callbacks turn into a pyramid of doom and raw Promise chains get noisy fast. async and await don't replace Promises — they're syntactic sugar that sits on top of them, letting you write async code that reads like normal top-to-bottom synchronous code, which is how your brain naturally thinks.

By the end of this article you'll understand exactly why async/await exists, how it maps to the Promise model underneath, how to handle errors properly, and how to avoid the performance trap that catches most intermediate developers — running async operations in sequence when they could run in parallel. You'll write better, faster, more readable async code starting today.

What async and await Actually Do Under the Hood

The async keyword does one thing: it makes a function always return a Promise. That's it. If you return a plain value like 42, JavaScript quietly wraps it in Promise.resolve(42). This matters because it means every async function plugs seamlessly into the existing Promise ecosystem — you can chain .then() on an async function's return value if you ever need to.

The await keyword can only live inside an async function. It pauses that function's execution, hands control back to the event loop so other code can run, then resumes the function once the Promise it's waiting on settles. Critically, await does NOT block the thread — it blocks only that specific function's execution while the rest of your program continues running.

Think of async functions as generators with built-in Promise plumbing. The JavaScript engine essentially transforms your await expressions into .then() callbacks at compile time. This is why understanding Promises first makes async/await click immediately — you're not learning something new, you're learning a cleaner way to write what you already know.

This mental model prevents a lot of confusion. When something goes wrong with async/await, the debugging answer almost always lives in understanding the underlying Promise behavior.

asyncUnderTheHood.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637
// ─── Demonstrating that async functions always return a Promise ───

async function getWelcomeMessage() {
  // We return a plain string, but the async keyword
  // automatically wraps this in Promise.resolve('Hello!')
  return 'Hello from an async function!';
}

// Proof: call it and check what we get back
const result = getWelcomeMessage();
console.log(result instanceof Promise); // true — it's always a Promise

// Use .then() on it — totally valid because it IS a Promise
result.then((message) => console.log(message));

// ─── Demonstrating what await actually does ───

function simulateNetworkDelay(ms) {
  // Returns a Promise that resolves after `ms` milliseconds
  // This mimics a real API call or database query
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchUserProfile() {
  console.log('1. fetchUserProfile started');

  // await pauses THIS function but NOT the whole program.
  // The event loop is free to run other code during this wait.
  await simulateNetworkDelay(1000);

  console.log('3. fetchUserProfile resumed after 1 second');
  return { id: 42, name: 'Sarah Connor', role: 'engineer' };
}

console.log('0. Before calling fetchUserProfile');
fetchUserProfile().then((user) => console.log('4. Got user:', user.name));
console.log('2. After calling fetchUserProfile — proof the thread is NOT blocked');
▶ Output
true
Hello from an async function!
0. Before calling fetchUserProfile
1. fetchUserProfile started
2. After calling fetchUserProfile — proof the thread is NOT blocked
(... 1 second passes ...)
3. fetchUserProfile resumed after 1 second
4. Got user: Sarah Connor
🔥
Key Mental Model:async/await is compiled Promise chains. Every await X is equivalent to .then(() => X). Knowing this means you can debug any async/await problem by mentally translating it back into Promise syntax.

Error Handling in async/await — Do This, Not That

This is where most developers take a wrong turn. A rejected Promise inside an async function that has no error handling will give you an UnhandledPromiseRejection warning and silently swallow the error in some environments. You need a strategy — and the right strategy depends on your context.

The most readable pattern is try/catch, which lets you handle errors exactly the way you'd handle synchronous errors. This is one of the biggest wins of async/await — unified error handling between sync and async code in a single try/catch block.

But try/catch has a trap: if you need to differentiate between different kinds of failures in one block, it gets messy. In those cases, a small helper pattern — sometimes called a 'safe await' or 'to' pattern — lets you handle errors inline without a try/catch tower.

The golden rule: never use async/await without an error handling strategy. An async function that can fail and has no .catch() or try/catch is a time bomb. In production Node.js apps, unhandled Promise rejections can crash the process entirely since Node 15.

asyncErrorHandling.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// ─── Pattern 1: try/catch — most readable for most situations ───

async function loadUserDashboard(userId) {
  try {
    // Both of these can throw — one try/catch covers both
    const userResponse = await fetch(`https://api.example.com/users/${userId}`);

    if (!userResponse.ok) {
      // Manually throw so we catch HTTP errors, not just network errors
      throw new Error(`HTTP ${userResponse.status}: Failed to load user ${userId}`);
    }

    const user = await userResponse.json(); // This can also throw if JSON is malformed
    console.log(`Dashboard loaded for: ${user.name}`);
    return user;

  } catch (error) {
    // One place to handle every failure in this async flow
    console.error('Dashboard failed to load:', error.message);
    return null; // Return a safe fallback so callers don't blow up
  }
}

// ─── Pattern 2: The 'safeAwait' helper — great for granular error handling ───
// Instead of nested try/catches, wrap each await to get [error, result] tuples
// Inspired by Go's error handling style

async function safeAwait(promise) {
  try {
    const data = await promise;
    return [null, data]; // [error, result] — error is null on success
  } catch (error) {
    return [error, null]; // error is populated on failure
  }
}

async function processOrder(orderId) {
  // Step 1: Validate the order
  const [validationError, order] = await safeAwait(
    fetch(`https://api.example.com/orders/${orderId}`).then((r) => r.json())
  );

  if (validationError) {
    console.error('Order validation failed:', validationError.message);
    return;
  }

  // Step 2: Charge the customer — different error, different handling
  const [paymentError, receipt] = await safeAwait(
    fetch('https://api.example.com/payments', {
      method: 'POST',
      body: JSON.stringify({ orderId: order.id, amount: order.total }),
    }).then((r) => r.json())
  );

  if (paymentError) {
    // Handle payment failure specifically — maybe alert the user differently
    console.error('Payment failed — order preserved for retry:', paymentError.message);
    return;
  }

  console.log(`Order ${orderId} confirmed. Receipt ID: ${receipt.id}`);
}

// ─── Pattern 3: Propagate errors upward intentionally ───
// Sometimes you WANT the error to bubble up to the caller

async function getProductPrice(productId) {
  const response = await fetch(`https://api.example.com/products/${productId}`);
  if (!response.ok) throw new Error(`Product ${productId} not found`);
  const product = await response.json();
  return product.price;
  // No try/catch here — the caller decides how to handle the failure
}

// The caller owns the error strategy
async function renderProductPage(productId) {
  try {
    const price = await getProductPrice(productId);
    console.log(`Price: $${price}`);
  } catch (error) {
    console.log('Could not load price. Showing "Contact for pricing" instead.');
  }
}
▶ Output
// Pattern 1 success path:
Dashboard loaded for: Sarah Connor

// Pattern 1 failure path:
Dashboard failed to load: HTTP 404: Failed to load user 999

// Pattern 2 payment failure:
Payment failed — order preserved for retry: Card declined

// Pattern 3 fallback:
Could not load price. Showing "Contact for pricing" instead.
⚠️
Watch Out:fetch() only rejects on network failure — not on 4xx or 5xx HTTP errors. Always check response.ok or response.status after awaiting a fetch call. Forgetting this is one of the most common bugs in JavaScript apps.

Sequential vs Parallel Execution — The Performance Trap

This is the mistake that separates developers who understand async from those who just use it. When you write multiple await statements one after another, they run sequentially — each one waits for the previous to finish before starting. For operations that are independent of each other, this is a massive and completely unnecessary performance hit.

Imagine you need a user's profile, their recent orders, and their notification count to render a dashboard. None of those three requests depends on the others. Running them sequentially means if each takes 300ms, your dashboard takes 900ms. Running them in parallel means it takes 300ms — the time of the slowest one.

Promise.all() is your tool here. It fires all Promises at once and waits for all of them to resolve, giving you a big performance win for independent operations. Use Promise.allSettled() when you want all results regardless of whether some fail — it never rejects, it gives you an array of outcome objects.

Promise.race() resolves or rejects as soon as the first Promise settles — great for implementing timeouts. Promise.any() resolves with the first success and only rejects if all fail — useful for redundant data sources like CDN fallbacks.

parallelVsSequential.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
// ─── The slow way: Sequential (bad for independent operations) ───

async function loadDashboardSlowly(userId) {
  console.time('sequential');

  // Each await WAITS for the previous one to finish.
  // Total time = 400ms + 350ms + 300ms = 1050ms
  const userProfile = await fetchUserProfile(userId);       // 400ms
  const recentOrders = await fetchRecentOrders(userId);     // 350ms  
  const notifications = await fetchNotifications(userId);   // 300ms

  console.timeEnd('sequential'); // ~1050ms
  return { userProfile, recentOrders, notifications };
}

// ─── The fast way: Parallel with Promise.all() ───

async function loadDashboardFast(userId) {
  console.time('parallel');

  // All three fetches START at the same time.
  // Total time ≈ max(400ms, 350ms, 300ms) = 400ms
  const [userProfile, recentOrders, notifications] = await Promise.all([
    fetchUserProfile(userId),    // Fires immediately
    fetchRecentOrders(userId),   // Also fires immediately
    fetchNotifications(userId),  // Also fires immediately
  ]);

  // Note: if ANY of these rejects, the whole Promise.all rejects.
  // Use Promise.allSettled if you want partial results on failure.

  console.timeEnd('parallel'); // ~400ms
  return { userProfile, recentOrders, notifications };
}

// ─── When some requests CAN fail without breaking everything ───

async function loadDashboardResilient(userId) {
  const results = await Promise.allSettled([
    fetchUserProfile(userId),
    fetchRecentOrders(userId),
    fetchNotifications(userId),
  ]);

  // allSettled never throws — each result has a status
  const [profileResult, ordersResult, notificationsResult] = results;

  const dashboard = {
    // Show user profile if loaded, otherwise show a fallback
    user: profileResult.status === 'fulfilled'
      ? profileResult.value
      : { name: 'Guest', role: 'unknown' },

    // Show orders if loaded, otherwise show empty state
    orders: ordersResult.status === 'fulfilled'
      ? ordersResult.value
      : [],

    // Notification count can silently fail — not critical
    notificationCount: notificationsResult.status === 'fulfilled'
      ? notificationsResult.value.length
      : 0,
  };

  if (ordersResult.status === 'rejected') {
    console.warn('Orders failed to load:', ordersResult.reason.message);
  }

  return dashboard;
}

// ─── Mock fetch helpers to make this runnable ───
function fetchUserProfile(userId) {
  return new Promise((resolve) =>
    setTimeout(() => resolve({ id: userId, name: 'Alex Rivera', role: 'admin' }), 400)
  );
}
function fetchRecentOrders(userId) {
  return new Promise((resolve) =>
    setTimeout(() => resolve([{ id: 'ORD-001', total: 89.99 }]), 350)
  );
}
function fetchNotifications(userId) {
  return new Promise((resolve) =>
    setTimeout(() => resolve([{ msg: 'Your order shipped' }, { msg: 'Flash sale today' }]), 300)
  );
}

// Run the fast version
loadDashboardFast(7).then((data) => {
  console.log('User:', data.userProfile.name);
  console.log('Orders:', data.recentOrders.length);
  console.log('Notifications:', data.notifications.length);
});
▶ Output
parallel: 401ms
User: Alex Rivera
Orders: 1
Notifications: 2
⚠️
Pro Tip:A quick way to audit your own async code: scan for multiple awaits in a row. Ask yourself 'does request B need request A's result to run?' If the answer is no, those awaits should be inside Promise.all(). This single habit can cut API-heavy page load times by 50-70%.

Real-World Pattern — Building a Resilient API Service

Knowing the mechanics is one thing. Knowing how to structure async code in a real application is another. Most production JavaScript — whether Node.js servers or React apps — wraps its async logic in service layers: plain modules that own all the fetch/database logic and export clean async functions.

This pattern keeps your components or route handlers lean. They just call await userService.getProfile(id) and handle the result. The async complexity is encapsulated in one place.

Let's also add a timeout pattern, because every real API call needs one. You can't let a slow third-party API hang your server forever. Promise.race() between your actual request and a timeout Promise is the classic solution.

Finally, adding a retry mechanism for transient failures is something every production app needs. A request that fails once due to a brief network hiccup should retry before giving up. These three ideas — service layer, timeout, and retry — are the building blocks of resilient async code.

resilientApiService.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// ─── A production-quality async service with timeout and retry ───

// Utility: Rejects after `ms` milliseconds — used for timeouts
function createTimeoutPromise(ms, operationName) {
  return new Promise((_, reject) =>
    setTimeout(
      () => reject(new Error(`${operationName} timed out after ${ms}ms`)),
      ms
    )
  );
}

// Utility: Retries a Promise-returning function up to `maxAttempts` times
async function withRetry(asyncFn, maxAttempts = 3, delayMs = 500) {
  let lastError;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await asyncFn(); // Try running the function
    } catch (error) {
      lastError = error;

      const isLastAttempt = attempt === maxAttempts;
      if (isLastAttempt) break;

      console.warn(`Attempt ${attempt} failed. Retrying in ${delayMs}ms...`);
      // Wait before retrying — prevents hammering a struggling server
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }
  }

  throw lastError; // All retries exhausted — propagate the final error
}

// ─── The actual service layer ───
const WEATHER_API_TIMEOUT_MS = 5000;

async function fetchWeatherData(cityName) {
  // Race: the real request vs a timeout — whoever settles first wins
  const weatherPromise = fetch(
    `https://api.openweathermap.org/data/2.5/weather?q=${cityName}&appid=DEMO_KEY`
  ).then((response) => {
    if (!response.ok) {
      throw new Error(`Weather API error: ${response.status} for city '${cityName}'`);
    }
    return response.json();
  });

  return Promise.race([
    weatherPromise,
    createTimeoutPromise(WEATHER_API_TIMEOUT_MS, `fetchWeatherData(${cityName})`),
  ]);
}

// Public API of our weather service — this is what callers use
const weatherService = {
  async getWeather(cityName) {
    // Wrap the fetch in our retry utility — handles transient failures
    const rawData = await withRetry(
      () => fetchWeatherData(cityName),
      3,   // up to 3 attempts
      800  // 800ms between retries
    );

    // Transform the raw API response into a clean shape our app cares about
    return {
      city: rawData.name,
      country: rawData.sys.country,
      temperatureCelsius: Math.round(rawData.main.temp - 273.15),
      description: rawData.weather[0].description,
      humidity: rawData.main.humidity,
      fetchedAt: new Date().toISOString(),
    };
  },

  async getWeatherForMultipleCities(cityNames) {
    // Fetch all cities in parallel — don't make users wait for each one
    const weatherResults = await Promise.allSettled(
      cityNames.map((city) => this.getWeather(city))
    );

    return weatherResults.map((result, index) => ({
      city: cityNames[index],
      success: result.status === 'fulfilled',
      data: result.status === 'fulfilled' ? result.value : null,
      error: result.status === 'rejected' ? result.reason.message : null,
    }));
  },
};

// ─── Usage — clean and readable because the complexity is hidden ───
async function renderWeatherDashboard() {
  const cities = ['London', 'Tokyo', 'New York'];

  console.log(`Fetching weather for: ${cities.join(', ')}`);

  const results = await weatherService.getWeatherForMultipleCities(cities);

  results.forEach(({ city, success, data, error }) => {
    if (success) {
      console.log(`${city}: ${data.temperatureCelsius}°C, ${data.description}`);
    } else {
      console.log(`${city}: Failed to load — ${error}`);
    }
  });
}

renderWeatherDashboard();
▶ Output
Fetching weather for: London, Tokyo, New York
London: 14°C, light rain
Tokyo: 22°C, clear sky
New York: Failed to load — Weather API error: 401 for city 'New York'
🔥
Architecture Note:The withRetry and createTimeoutPromise utilities here are framework-agnostic — drop them into any Node.js, React, or vanilla JavaScript project. Libraries like axios-retry do similar things, but knowing how to build it yourself means you can customize behavior (exponential backoff, retry only on specific status codes) instead of being limited by someone else's API.
Feature / AspectPromise .then()/.catch()async / await
ReadabilityChains get noisy with multiple stepsReads like synchronous code — linear and clean
Error handling.catch() at the end of a chaintry/catch blocks — same syntax as sync errors
Debugging stack tracesStack traces often lose call context across .then()Stack traces are more accurate and readable
Conditional async logicNested .then() chains, hard to readSimple if/else inside the async function
Parallel executionPromise.all() directlyawait Promise.all([...]) — same tool, cleaner syntax
Learning curveMust understand Promise constructor and chainingSlightly easier entry point, but requires Promise knowledge
Top-level usageWorks anywhereRequires async function wrapper (or top-level await in modules)
Return valueReturns a Promise — explicitReturns a Promise — automatic, can surprise beginners

🎯 Key Takeaways

  • async functions always return a Promise — even if you return a plain value like a string or number, it gets wrapped in Promise.resolve() automatically.
  • await only blocks the current async function's execution, not the JavaScript thread — the event loop continues running other code while it waits.
  • Multiple consecutive awaits on independent operations is a performance bug — use Promise.all() to run independent async operations in parallel and cut total wait time to the slowest single request.
  • fetch() does NOT reject on 4xx/5xx HTTP errors — you must manually check response.ok or response.status after every await fetch(...) call, or you'll silently miss server errors.

⚠ Common Mistakes to Avoid

  • Mistake 1: Using await inside a non-async function — This causes a SyntaxError: 'await is only valid in async functions'. If you forget the async keyword on a function that contains await, nothing runs. Fix: always add async to any function that contains an await expression, including callbacks like array methods (e.g., arr.map(async (item) => await doSomething(item))).
  • Mistake 2: Awaiting inside a .forEach() loop and expecting it to work serially — forEach ignores returned Promises, so all iterations fire simultaneously AND the outer function doesn't wait for any of them. Symptom: code after the forEach runs before the async operations inside finish. Fix: replace forEach with a for...of loop and await inside it for serial execution, or use Promise.all(arr.map(async (item) => ...)) for parallel execution.
  • Mistake 3: Not handling rejected Promises from async functions — Calling an async function without await and without .catch() means any rejection becomes an unhandled Promise rejection. Symptom: silent failures in development, process crashes in Node.js 15+ production. Fix: always either await the call inside a try/catch, or append .catch() to the Promise returned by the async function call.

Interview Questions on This Topic

  • QWhat is the difference between Promise.all() and Promise.allSettled()? When would you choose one over the other?
  • QIf you have three independent API calls in an async function and you write three consecutive await statements, what's the performance problem — and how would you fix it?
  • QCan you use await outside of an async function? What happens if you try, and is there any context where top-level await is valid?

Frequently Asked Questions

Can I use async await with forEach in JavaScript?

Technically yes, but it almost certainly won't do what you expect. forEach ignores returned Promises, so await inside a forEach callback doesn't pause the outer function — all iterations fire at once and your code moves on before any finish. Use a for...of loop with await for sequential processing, or Promise.all() with .map() for parallel processing.

What's the difference between async await and Promises in JavaScript?

async/await is built directly on top of Promises — it doesn't replace them. async/await is syntactic sugar that lets you write Promise-based code in a linear, synchronous-looking style. Under the hood, every await is a .then() call. You still need to understand Promises to use async/await effectively, especially for parallel patterns like Promise.all().

Does await block the entire JavaScript program?

No — this is a crucial distinction. await pauses only the specific async function it's inside. The JavaScript event loop keeps running, handling other callbacks, timers, and events while your async function waits. This is what makes Node.js able to handle thousands of concurrent requests despite being single-threaded.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousPromises in JavaScriptNext →Event Loop in JavaScript
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged