🧩 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.
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.
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.
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.
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.
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:
- Accept
files, an asyncupload(file)worker, and alimit(default 3). - Never run more than
limituploads simultaneously. - 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.