Skip to main content

🧩 Common Async/Await Patterns

Individual awaits are easy; composing them well is the skill. This lesson gives you a toolbox of battle-tested patterns — sequential waterfalls, parallel fan-out, controlled-concurrency batching, and the circuit breaker — so you can pick the right shape for each real-world problem.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Recognise when a task is a waterfall (dependent steps) versus fan-out (independent tasks)
  • Run independent work in parallel with Promise.all() and understand its all-or-nothing failure
  • Choose Promise.allSettled() when partial success is acceptable
  • Cap concurrency with a batching loop so you don't overwhelm an API or the network
  • Protect a struggling service with a circuit breaker

Estimated Time: 35–45 minutes  •  Difficulty: Intermediate

Hands-on: Build a batch processor that limits how many requests run at once.

In This Lesson

A Map of the Patterns

Every async problem is some mix of two questions: do these tasks depend on each other? and can the downstream service handle them all at once? Your answers point you at one of a handful of patterns.

graph TD A["Async task"] --> B{"Do steps depend
on each other?"} B -->|Yes| C["Waterfall
(sequential await)"] B -->|No| D{"Can the service take
them all at once?"} D -->|Yes| E["Fan-out
Promise.all / allSettled"] D -->|No| F["Batching
(limited concurrency)"] C --> G{"Service flaky?"} E --> G F --> G G -->|Yes| H["Wrap in retry /
circuit breaker"]
💡 Patterns, not rules: real features often combine these — a waterfall whose one step fans out, wrapped in a circuit breaker. Learn each shape on its own, then mix.

The Waterfall (Sequential)

A waterfall runs steps in order because each one needs the previous result. Here, permissions depend on the user, and the dashboard depends on both. There is no way to parallelise a true dependency — and that's fine.

A sequential waterfall of dependent steps Get user feeds into get permissions, which feeds into build dashboard. Each arrow shows a dependency on the previous step's output. Get user /api/users/:id Get permissions needs user.roleId Build dashboard needs both above
Figure 1 — In a waterfall each step consumes the previous step's output, so the steps must run one after another.
async function buildDashboard(userId) {
  try {
    const user = await getUser(userId);                 // step 1
    const permissions = await getPermissions(user.roleId); // step 2 needs step 1
    const content = await getContent(permissions);      // step 3 needs step 2
    return renderDashboard({ user, permissions, content });
  } catch (error) {
    console.error('Dashboard build failed:', error);
    throw error;
  }
}

⚠️ Don't fake dependencies

Only keep steps sequential if there's a real data dependency. Accidentally sequential awaits over independent work is the most common performance bug in async code — the next pattern fixes it.

Fan-out with Promise.all

When tasks are independent, start them together and await them as a group with Promise.all(). The total time collapses to roughly the slowest single task instead of the sum.

sequenceDiagram participant M as loadProfile() participant A as fetch profile participant B as fetch posts participant C as fetch friends M->>A: start M->>B: start M->>C: start A-->>M: done B-->>M: done C-->>M: done Note over M: Promise.all resolves
async function loadProfile(userId) {
  const [profile, posts, friends] = await Promise.all([
    fetchProfile(userId),
    fetchPosts(userId),
    fetchFriends(userId),
  ]);
  return { profile, posts, friends };
}

⚠️ Promise.all is all-or-nothing

Promise.all() rejects as soon as any one promise rejects, and you lose the results of the others that succeeded. That's perfect when you need every piece, but wrong when partial data is still useful — see the next pattern.

Partial Success: allSettled

Promise.allSettled() waits for every promise and never rejects. You get an array describing each outcome — { status: 'fulfilled', value } or { status: 'rejected', reason } — so one failure doesn't sink the rest.

async function loadWidgets(userId) {
  const results = await Promise.allSettled([
    fetchWeather(userId),
    fetchStocks(userId),
    fetchCalendar(userId),
  ]);

  // Keep the widgets that loaded; log the ones that didn't.
  return results
    .map((result, i) => {
      if (result.status === 'fulfilled') return result.value;
      console.warn(`Widget ${i} failed:`, result.reason);
      return null; // render a placeholder instead
    })
    .filter(Boolean);
}

💡 Choosing between them

Use Promise.all() when the whole operation is meaningless without every result (a database transaction's steps). Use Promise.allSettled() for dashboards and feeds where showing three of four widgets still helps the user.

Controlled Concurrency (Batching)

Firing 10,000 requests at once with Promise.all() will exhaust connections, hit rate limits, or crash the server you're calling. Batching processes a fixed number at a time: parallel within a batch, sequential between batches.

Batched concurrency Items are grouped into batches of three. Each batch runs its three items in parallel, and batches run one after another along the time axis. Batch 1 item 1 item 2 item 3 Batch 2 item 4 item 5 item 6 Batch 3 item 7 item 8 time
Figure 2 — Items run in parallel within a batch, while batches run one after another, capping how many requests are ever in flight at once.
async function processInBatches(items, worker, batchSize = 5) {
  const results = [];
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    // Parallel within the batch...
    const batchResults = await Promise.all(batch.map(worker));
    results.push(...batchResults);
    // ...then the loop moves to the next batch (sequential between batches)
    console.log(`Finished batch ${i / batchSize + 1}`);
  }
  return results;
}

// Fetch 1,000 users, but never more than 5 requests in flight at once
const users = await processInBatches(userIds, (id) => fetchUser(id), 5);

The Circuit Breaker

When a downstream service is down, hammering it with retries makes things worse and ties up your own resources. A circuit breaker "trips" after a threshold of failures: while open it fails fast without calling the service, and after a cooldown it lets one test request through to see if the service recovered.

stateDiagram-v2 [*] --> Closed Closed --> Open: failures exceed threshold Open --> HalfOpen: cooldown elapsed HalfOpen --> Closed: test call succeeds HalfOpen --> Open: test call fails
class CircuitBreaker {
  constructor(action, { failureThreshold = 5, cooldownMs = 30000 } = {}) {
    this.action = action;
    this.failureThreshold = failureThreshold;
    this.cooldownMs = cooldownMs;
    this.state = 'CLOSED';       // CLOSED | OPEN | HALF_OPEN
    this.failures = 0;
    this.openedAt = 0;
  }

  async execute(...args) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.openedAt < this.cooldownMs) {
        throw new Error('Circuit is open — service unavailable');
      }
      this.state = 'HALF_OPEN'; // time to test the waters
    }

    try {
      const result = await this.action(...args);
      this.reset();             // success closes the circuit
      return result;
    } catch (error) {
      this.recordFailure();
      throw error;
    }
  }

  recordFailure() {
    this.failures++;
    if (this.state === 'HALF_OPEN' || this.failures >= this.failureThreshold) {
      this.state = 'OPEN';
      this.openedAt = Date.now();
    }
  }

  reset() {
    this.state = 'CLOSED';
    this.failures = 0;
  }
}

// Usage
const api = new CircuitBreaker((path) => fetchJSON(`/api/${path}`));
try {
  const data = await api.execute('users/123');
} catch (error) {
  // Fails fast while the circuit is open instead of waiting on a dead service
  console.warn(error.message);
}

✅ Retry and circuit breaker are partners

Retries handle brief blips; the circuit breaker handles sustained outages. Together they let your app degrade gracefully instead of cascading into failure.

Hands-on Exercise

🏋️ A Concurrency-Limited Uploader

Objective: Upload a long list of files, but keep at most N uploads running at once and don't let one failure abort the rest.

Requirements:

  1. Accept files, an async upload(file) worker, and a limit (default 3).
  2. Never run more than limit uploads simultaneously.
  3. Return a summary: how many succeeded and which files failed (don't throw on a single failure).
💡 Hint

Combine the batching loop with Promise.allSettled() instead of Promise.all() so a failed upload becomes a rejected result rather than aborting the batch.

✅ Solution
async function uploadAll(files, upload, limit = 3) {
  const succeeded = [];
  const failed = [];

  for (let i = 0; i < files.length; i += limit) {
    const batch = files.slice(i, i + limit);
    const outcomes = await Promise.allSettled(batch.map(upload));

    outcomes.forEach((outcome, j) => {
      const file = batch[j];
      if (outcome.status === 'fulfilled') {
        succeeded.push(file);
      } else {
        failed.push({ file, reason: outcome.reason });
      }
    });
  }

  return { succeededCount: succeeded.length, failed };
}

// const report = await uploadAll(myFiles, uploadToS3, 3);
// report.failed lists exactly which uploads to retry later

At most three uploads run at a time, and a single bad file lands in failed instead of stopping everything.

🎯 Quick Quiz

Question 1: Three independent API calls with no dependencies between them. Which pattern fits best?

Question 2: You're loading four dashboard widgets and want to still show the ones that succeed if one fails. Which combinator?

Question 3: Why use batching instead of one big Promise.all() over 10,000 requests?

Summary & Quiz

🎉 Key Takeaways

  • Waterfall: sequential awaits for genuinely dependent steps — don't fake the dependency.
  • Fan-out: Promise.all() runs independent tasks in parallel, but rejects if any one fails.
  • Partial success: Promise.allSettled() reports every outcome and never rejects.
  • Batching: cap concurrency so you don't exhaust connections or trip rate limits.
  • Circuit breaker: fail fast during outages, then probe for recovery — the perfect partner to retries.

📚 Further Reading

🚀 What's Next?

You now have a vocabulary of async patterns. Next we put them to work against the browser's most-used async API — the Fetch API — learning to make requests, read responses, and handle real HTTP.

🎉 Excellent!

Pick the right pattern and your async code stays fast, resilient, and readable.