Skip to content

JavaScript Async/Await: Errors, Parallel Calls, Loops

JavaScript async await makes asynchronous code read like synchronous code. Learn error handling, parallel calls with Promise.all, and common pitfalls.

· · 5 min read
JavaScript source code with line numbers displayed in a dark-theme code editor

Quick Take

Async/await is syntax sugar over Promises that lets asynchronous JavaScript read top to bottom like ordinary code. The three things that trip people up are error handling, running calls in parallel, and awaiting inside loops.

Quick take: Async/await is syntax over Promises. Every async function returns a Promise, and await pauses until that Promise settles. Handle failures with try/catch, run independent calls together with Promise.all, and never await inside forEach.

JavaScript async await lets you write asynchronous code that reads like synchronous code, without the nesting of .then() chains. It landed in ES2017 and is now the default way to handle Promises. An async function always returns a Promise. The await keyword pauses that function until the awaited Promise resolves or rejects, then hands you the resolved value directly. You handle errors with a normal try/catch block. You run independent operations at the same time by starting them first and passing them to Promise.all. The two mistakes that show up most in production are awaiting calls one at a time when they could run in parallel, and using await inside Array.forEach, which silently does nothing. Get those two right and async code stops being scary.

I've debugged more broken async code than I can count. The bugs are almost never exotic. They're the same three patterns over and over. Let's walk through them.

What Is Async/Await in JavaScript?

Here's the same fetch written both ways. Which one would you rather read at 2am during an incident?

// Promise chain
function getUser(id) {
  return fetch(`/api/users/${id}`)
    .then(res => res.json())
    .then(user => fetch(`/api/orders/${user.id}`))
    .then(res => res.json());
}

// Async/await - same logic, flat structure
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  const user = await res.json();
  const orders = await fetch(`/api/orders/${user.id}`);
  return orders.json();
}

Both do exactly the same thing. The second version reads top to bottom. That's the whole pitch. await only works inside an async function (or at the top level of an ES module), and it unwraps the Promise so user is the actual object, not a pending Promise.

A JavaScript project file tree showing async.js, useTracker.js, App.jsx, and index.js
Photo by Rahul Mishra on Unsplash

How Do You Handle Errors with Async/Await?

This is where the syntax really pays off. With Promise chains you need .catch(). With async/await you use the try/catch you already know from synchronous code.

async function loadDashboard(userId) {
  try {
    const res = await fetch(`/api/users/${userId}`);
    if (!res.ok) {
      throw new Error(`Request failed: ${res.status}`);
    }
    return await res.json();
  } catch (err) {
    console.error('Dashboard load failed:', err.message);
    return null;
  }
}

One trap here: fetch does not reject on a 404 or 500. It only rejects on a network failure. So you have to check res.ok yourself and throw. I've seen this cost a team half a day, they assumed a failed HTTP status would land in the catch block, and it never did. When you type these responses with TypeScript generics, the compiler nudges you toward handling the error shape, but the runtime check still has to be explicit.

How Do You Run Async Calls in Parallel?

This is the single biggest performance win people miss. Look at this:

// Slow - runs sequentially, 600ms total
const user = await fetchUser();      // 200ms
const posts = await fetchPosts();    // 200ms
const stats = await fetchStats();    // 200ms

// Fast - runs concurrently, ~200ms total
const [user, posts, stats] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchStats(),
]);

If those three calls don't depend on each other, the sequential version wastes 400 milliseconds for no reason. Promise.all starts all three immediately and resolves when the slowest one finishes. On a real dashboard I refactored, moving six independent API calls from sequential awaits to a single Promise.all dropped initial load from 1.4 seconds to 380 milliseconds. Same requests, same server, just stopped waiting in line.

One caveat worth knowing: Promise.all rejects as soon as any single Promise rejects. If you need every result regardless of individual failures, use Promise.allSettled instead, it waits for all of them and reports each outcome separately.

Parallel light trails on a highway at night, evoking concurrent async calls
Photo by Robin Pierre on Unsplash

What Are the Most Common Async/Await Mistakes?

The forEach trap catches everyone once. This looks correct and is completely broken:

// BROKEN - forEach ignores the returned Promise
items.forEach(async (item) => {
  await save(item);
});
console.log('done'); // logs immediately, saves are still running

Array.forEach throws away whatever the callback returns, so it never waits. The fix depends on what you want:

// Sequential - one save at a time, in order
for (const item of items) {
  await save(item);
}

// Concurrent - all saves at once
await Promise.all(items.map(item => save(item)));

Use for...of when order matters or you're rate-limited. Use the Promise.all version when the operations are independent and you want speed. My rule of thumb: reach for for...of first, because a runaway Promise.all over 10,000 items will happily open 10,000 connections and take down your database. Concurrency is a tool, not a default.

When Should You Still Use Promises Directly?

Async/await isn't always the answer. Do you actually need to pause? If you're just kicking off a fire-and-forget operation, or composing Promises with Promise.race and Promise.all, the raw Promise API is cleaner. And in React, awaiting directly inside a component body doesn't work, you handle async work inside useEffect or a data library instead. Our React hooks best practices guide covers the cleanup patterns that keep async effects from leaking, and React data fetching with TanStack Query removes most of the manual async plumbing entirely.

Async/await is one of the best additions JavaScript ever shipped. It didn't add new capability, Promises already did the heavy lifting, but it made asynchronous code readable enough that ordinary developers stopped getting it wrong. Learn the error handling, learn Promise.all, avoid forEach, and you've covered 90% of what you'll ever hit.

Frequently Asked Questions

What is the difference between async/await and Promises?
Async/await is syntax built on top of Promises. Every async function returns a Promise, and await pauses the function until a Promise settles. They are the same mechanism, await just lets you write it as sequential-looking code instead of chained .then() callbacks.
Do I need try/catch with every await?
Not every single await, but every logical unit that can fail. Wrap a related group of awaits in one try/catch, or let the rejection bubble up to a caller that handles it. An unhandled rejection in an async function will crash Node or fire an unhandledrejection event in the browser.
How do I run multiple async calls at the same time?
Start the calls first, then await them together with Promise.all. Awaiting each call on its own line runs them one after another. Promise.all([a(), b(), c()]) runs all three concurrently and resolves when the slowest finishes.
Can I use await inside a forEach loop?
No. Array.forEach ignores the returned Promise, so the awaits do not actually pause the loop. Use a for...of loop for sequential work, or map the array to Promises and pass them to Promise.all for concurrent work.
Is async/await slower than plain Promises?
No meaningful difference. Modern V8 optimizes async functions heavily, and the overhead is negligible next to the network or disk work you are awaiting. Readability wins almost every time, so reach for async/await by default.