Skip to main content

JavaScript Async/Await Patterns Complete Guide

Published: March 7, 2026 Updated: August 30, 2026 Larry Qu 7 min read

JavaScript’s async model is single-threaded but non-blocking. Understanding this — and knowing when to run things in parallel versus sequentially — is one of the most practical skills in modern JavaScript development. This guide covers async/await in depth, the four Promise combinators, and production patterns like retry, timeout, and concurrency queues.

How JavaScript Handles Asynchrony

JavaScript runs on a single thread. When you await a promise, the function suspends and control returns to the event loop, which can process other work (UI events, other timers, other pending callbacks) while the awaited operation runs. The function resumes when the awaited value resolves.

This means await is not blocking — it’s cooperative. It’s safe to await in a loop without freezing the browser or starving the server.

Promises: The Foundation

Before async/await syntax existed, Promises solved the callback hell problem. Async/await is syntactic sugar over Promises — understanding Promises deeply helps you debug async/await when things go wrong.

A Promise represents a value that will be available in the future. It starts pending and settles to either fulfilled (with a value) or rejected (with an error):

const fetchUser = (id) => new Promise((resolve, reject) => {
  setTimeout(() => {
    if (id > 0) resolve({ id, name: 'Alice' });
    else reject(new Error('Invalid user ID'));
  }, 100);
});

fetchUser(1)
  .then(user => console.log(user.name))   // 'Alice'
  .catch(err => console.error(err.message));

The Four Promise Combinators

Each combinator has a distinct semantics — choosing the wrong one is a common source of bugs:

// Promise.all — all must succeed; rejects immediately on any failure
// Use when you need ALL results and any failure should abort
const [user, posts] = await Promise.all([
  fetchUser(userId),
  fetchPosts(userId),
]);

// Promise.allSettled — waits for ALL to settle regardless of outcome
// Use when you want to know the status of each, even if some fail
const results = await Promise.allSettled([fetchUser(1), fetchUser(2)]);
results.forEach(r => {
  if (r.status === 'fulfilled') console.log(r.value);
  else console.error('Failed:', r.reason.message);
});

// Promise.race — resolves/rejects with the FIRST to settle
// Classic use case: implementing a timeout on a request
const data = await Promise.race([
  fetch('/api/data'),
  new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 5000)),
]);

// Promise.any — resolves with the FIRST success, ignores individual failures
// Use when you have fallbacks and any success is acceptable
const result = await Promise.any([
  fetchFromPrimaryAPI(),
  fetchFromBackupAPI(),
  fetchFromCacheAPI(),
]);

The key distinction: Promise.all fails fast on the first rejection, while Promise.allSettled always gives you all outcomes. Use allSettled when you’re doing independent batch operations like sending notifications where partial success is acceptable.

Async/Await in Practice

Async/await makes asynchronous code look synchronous, which dramatically improves readability for sequential operations:

async function getUserWithOrders(userId) {
  try {
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id);     // sequential: needs user.id first
    const recent = orders.filter(o => o.recent);
    return { user, orders: recent };
  } catch (err) {
    // Any rejection in the try block lands here
    throw new Error(`Failed to load user ${userId}: ${err.message}`);
  }
}

Sequential vs Parallel — The Critical Difference

The most common async performance mistake is awaiting operations sequentially when they could run in parallel:

// ❌ Sequential — 2000ms total (operations run one after another)
const user   = await fetchUser(id);      // 1000ms
const config = await fetchConfig();      // 1000ms

// ✅ Parallel — ~1000ms total (operations run concurrently)
const [user, config] = await Promise.all([
  fetchUser(id),
  fetchConfig(),
]);

Use sequential awaits only when operations have data dependencies — when each step needs the result of the previous step. If they’re independent, always use Promise.all.

A common mistake in loops is awaiting inside forEach, which doesn’t work as expected:

// ❌ forEach doesn't await — all fetches fire but results are ignored
userIds.forEach(async (id) => {
  const user = await fetchUser(id);
  console.log(user);   // This runs, but forEach has already moved on
});

// ✅ for...of respects await — truly sequential
for (const id of userIds) {
  const user = await fetchUser(id);
  console.log(user);
}

// ✅ Promise.all — parallel fetch for all IDs
const users = await Promise.all(userIds.map(id => fetchUser(id)));

Production Patterns

Retry with Exponential Backoff

Transient failures (network blips, rate limits, database connection timeouts) are common in distributed systems. A retry utility with exponential backoff is one of the most reusable async utilities you’ll write:

async function retry(fn, { maxAttempts = 3, baseDelayMs = 500, shouldRetry = () => true } = {}) {
  let lastError;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;
      if (attempt === maxAttempts || !shouldRetry(err)) throw err;

      // Exponential backoff with jitter: 500ms, 1000ms, 2000ms + random offset
      const delay = baseDelayMs * 2 ** (attempt - 1) + Math.random() * 200;
      console.warn(`Attempt ${attempt} failed: ${err.message}. Retrying in ${delay.toFixed(0)}ms...`);
      await new Promise(r => setTimeout(r, delay));
    }
  }
  throw lastError;
}

// Only retry on network errors, not 4xx client errors
const data = await retry(
  () => fetch('/api/data').then(r => {
    if (!r.ok) throw Object.assign(new Error(`HTTP ${r.status}`), { status: r.status });
    return r.json();
  }),
  {
    maxAttempts: 4,
    shouldRetry: (err) => !err.status || err.status >= 500,
  }
);

The jitter (Math.random() * 200) is important when many clients retry simultaneously — without it, they all retry at exactly the same moment, causing a “retry storm.”

Timeout Wrapper

Promise.race is the building block for timeouts. Wrap it in a utility so you don’t repeat the boilerplate:

function withTimeout(promise, timeoutMs, message = 'Operation timed out') {
  let timeoutHandle;
  const timeout = new Promise((_, reject) => {
    timeoutHandle = setTimeout(() => reject(new Error(message)), timeoutMs);
  });

  return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutHandle));
}

// Fail fast if the API takes more than 5 seconds
const user = await withTimeout(fetchUser(id), 5000, 'User fetch timed out');

Clearing the timeout in finally is important — without it, the timer fires even after the promise resolves, causing spurious errors.

Concurrency Queue

Sometimes you need to process many items concurrently but with a cap — for example, downloading 1000 files but only 5 at a time to avoid overwhelming a server:

async function pLimit(tasks, concurrency) {
  const results = [];
  let index = 0;

  async function worker() {
    while (index < tasks.length) {
      const taskIndex = index++;
      results[taskIndex] = await tasks[taskIndex]();
    }
  }

  // Start `concurrency` workers — they each pull from the queue
  await Promise.all(Array.from({ length: concurrency }, worker));
  return results;
}

// Download 100 files but only 5 concurrently
const files = urls.map(url => () => fetch(url).then(r => r.blob()));
const blobs = await pLimit(files, 5);

Async Generators and for-await-of

Async generators produce values asynchronously over time — useful for paginated APIs, streaming responses, and processing large datasets without loading everything into memory first:

// Paginated API consumer: yields one page at a time, fetching on demand
async function* paginatedFetch(baseUrl) {
  let page = 1;
  while (true) {
    const res = await fetch(`${baseUrl}?page=${page}&limit=20`);
    const { items, hasMore } = await res.json();
    yield items;
    if (!hasMore) break;
    page++;
  }
}

// Process records page by page without loading the full dataset
for await (const page of paginatedFetch('/api/users')) {
  await processPage(page);   // Process 20 items at a time
}

This pattern is memory-efficient — you only hold one page in memory at a time, regardless of how many total records there are.

Promise.withResolvers (ES2024)

Promise.withResolvers() gives you a promise plus its resolve/reject functions as a plain object, eliminating the constructor callback pattern for cases where resolve/reject need to be called from outside:

// Before ES2024 — resolve/reject captured via closure
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });

// ES2024 — cleaner
const { promise, resolve, reject } = Promise.withResolvers();

// Example: resolve from a different event handler
eventEmitter.on('done', resolve);
eventEmitter.on('error', reject);
const result = await promise;

Error Handling Strategies

Two valid patterns — try/catch for imperative style, .catch() for functional pipelines:

// try/catch — natural when you need to handle different error types
async function loadDashboard(userId) {
  try {
    const [user, stats] = await Promise.all([fetchUser(userId), fetchStats(userId)]);
    return { user, stats };
  } catch (err) {
    if (err.status === 404) return { user: null, stats: null };
    throw err;   // Re-throw errors you can't handle here
  }
}

// .catch() inline — useful for providing defaults for specific operations
async function loadPage(id) {
  const [primary, fallback] = await Promise.all([
    fetchPrimary(id).catch(() => null),      // null on failure
    fetchSidebar().catch(() => defaultSidebar),  // default on failure
  ]);
  return { primary, fallback };
}

Summary

Async JavaScript mastery comes down to a few core habits:

  • Use Promise.all for independent concurrent operations; sequential await only when steps are data-dependent
  • Choose the right combinator: all for “all must succeed”, allSettled for “collect all outcomes”, race for timeouts, any for “first success wins”
  • Implement retry with exponential backoff and jitter for transient failures
  • Use for...of (not forEach) when you need sequential async iteration
  • Async generators are the right tool for paginated or streaming data sources

Resources

Comments

👍 Was this article helpful?