Programming Guides

Writing Clean Async JavaScript with async/await

async/await makes asynchronous code read like synchronous code — which is exactly why it hides easy mistakes. Patterns for flow, errors, and concurrency.

M. Calder9 min read

async/await is one of the best ergonomics JavaScript has added. It also makes it trivially easy to write sequential code where you wanted concurrent code, to swallow errors you meant to surface, and to leak promises you never awaited. Here are the patterns that keep async code honest.

The sequential trap

The most common mistake is awaiting in a loop when you meant to run things in parallel:

TypeScript
// Sequential — slow
const results = [];
for (const id of ids) {
  results.push(await fetchItem(id));
}

// Concurrent — fast
const results = await Promise.all(ids.map(id => fetchItem(id)));

Promise.all starts every fetch at once and resolves when all finish. The sequential version waits for each before starting the next. For ten network calls, the difference is often an order of magnitude.

The rule of thumb: if the operations do not depend on each other, do not await them one at a time.

When you do need sequencing

Sometimes order genuinely matters — for example, each call depends on the previous result. Then a loop is correct, and await in the body is right, not a mistake. The trap is applying "use Promise.all" as an unthinking rule.

Handling errors without swallowing them

A bare try/catch around an await is fine, but two anti-patterns recur:

  1. Catching and logging only. If you catch an error and do nothing but console.log, callers have no idea anything failed. Either rethrow, return a typed failure, or handle it genuinely.
  2. Catching too broadly. Wrapping an entire function body in one try hides which step failed.

A cleaner pattern for orchestration:

TypeScript
type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: Error };

async function safe<T>(p: Promise<T>): Promise<Result<T>> {
  try {
    return { ok: true, value: await p };
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e : new Error(String(e)) };
  }
}

Now failures are values, not exceptions, and the type system forces callers to handle both branches.

Concurrency with limits

Promise.all is all-or-nothing and unbounded. For large lists you usually want bounded concurrency. A small helper:

TypeScript
async function mapWithConcurrency<T, U>(
  items: T[],
  limit: number,
  fn: (item: T) => Promise<U>,
): Promise<U[]> {
  const results: U[] = new Array(items.length);
  let cursor = 0;
  async function worker() {
    while (cursor < items.length) {
      const i = cursor++;
      results[i] = await fn(items[i]);
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}

This runs at most limit operations at once, preserves order, and avoids the memory spike of starting a thousand promises simultaneously.

Takeaways

  • Await in a loop only when each step depends on the previous.
  • Make failures values when the caller needs to branch on them.
  • Bound concurrency for large collections.

Async code that reads synchronously is a gift, provided you remember that reading-order and execution-order are now two different things.

Related briefs