Advanced Async Patterns
Master async generators, async iteration, structured concurrency with AbortController, and advanced Promise combinators for robust asynchronous JavaScript.
Overview
Key Points
- •Async generators (async function*) yield Promises for-await-of awaits each yielded value in turn
- •AsyncIterable: any object with [Symbol.asyncIterator]() that returns an async iterator
- •AbortController.abort() cancels all operations that received its signal pass the same signal to related operations
- •AbortSignal.timeout(ms) creates a self-cancelling signal without a controller for simple timeout scenarios
- •Promise.allSettled() collects all results (fulfilled and rejected) without short-circuiting on failure
- •Promise.any() resolves as soon as the first promise fulfils ideal for racing multiple data sources
- •Concurrency limiting: run at most N promises simultaneously with a semaphore or batching pattern
- •Retry with exponential backoff: double the delay after each failure, add jitter to avoid thundering herd
Examples
Async generator for paginated API iteration
async function* fetchPages(baseUrl, pageSize = 20) {
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(`${baseUrl}?page=${page}&limit=${pageSize}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
yield data.items; // yield each page as a batch
hasMore = data.hasNextPage;
page++;
}
}
// Consume lazily only fetches the next page when needed
async function main() {
let total = 0;
for await (const page of fetchPages('https://api.example.com/items')) {
console.log(`Got ${page.length} items`);
total += page.length;
// Could break early here without fetching remaining pages
if (total >= 100) break;
}
console.log(`Total processed: ${total}`);
}The async generator fetches the next page only when the loop body requests it lazy evaluation in async form.
AbortController cancellation and timeout
// Cancel fetch when component unmounts
function fetchWithCleanup(url) {
const controller = new AbortController();
const promise = fetch(url, { signal: controller.signal })
.then(r => r.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch was cancelled');
return null;
}
throw err;
});
return { promise, cancel: () => controller.abort() };
}
const { promise, cancel } = fetchWithCleanup('https://api.example.com/data');
// Simulate component unmounting after 500ms
setTimeout(cancel, 500);
const data = await promise; // null if cancelled
// Built-in timeout via AbortSignal.timeout()
try {
const response = await fetch('https://api.example.com/slow', {
signal: AbortSignal.timeout(3000), // auto-cancels after 3s
});
const json = await response.json();
console.log(json);
} catch (err) {
if (err.name === 'TimeoutError') console.error('Request timed out');
else throw err;
}AbortSignal.timeout() is the modern alternative to wrapping fetch in a Promise.race with a timeout.
Concurrency limiting and retry with exponential backoff
// Run at most 'limit' async tasks simultaneously
async function withConcurrencyLimit(tasks, limit) {
const results = [];
const executing = new Set();
for (const task of tasks) {
const p = task().then(result => {
executing.delete(p);
return result;
});
executing.add(p);
results.push(p);
if (executing.size >= limit) {
await Promise.race(executing); // wait for one slot to free up
}
}
return Promise.all(results);
}
// Retry with exponential backoff + jitter
async function retry(fn, { attempts = 3, baseDelay = 500, jitter = 0.2 } = {}) {
for (let attempt = 0; attempt < attempts; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === attempts - 1) throw err;
const delay = baseDelay * 2 ** attempt;
const jitterMs = delay * jitter * (Math.random() * 2 - 1);
console.warn(`Attempt ${attempt + 1} failed, retrying in ${Math.round(delay + jitterMs)}ms`);
await new Promise(r => setTimeout(r, delay + jitterMs));
}
}
}
// Example: fetch with retry
const data = await retry(
() => fetch('https://flaky.api.com/data').then(r => r.json()),
{ attempts: 4, baseDelay: 250 }
);Jitter prevents the "thundering herd" problem where all retrying clients hit the server at the same instant after a failure.
Common Mistakes
- Not checking signal.aborted in long-running loops fetch respects AbortSignal, but your own async logic must check it manually between steps
- Awaiting Promise.all with a large array of independent promises, overwhelming the downstream API always limit concurrency for I/O-bound work
- Using for-await-of on a never-ending async generator without a break condition this hangs the program; always handle cancellation
- Swallowing AbortError as a generic catch-all always re-throw non-abort errors so bugs are not silently hidden
Note
For complex async workflows in production, consider the AbortSignal composition pattern: create a parent controller for the full operation, and pass parent.signal to every sub-operation. A single parent.abort() cascades cancellation through all layers fetch, stream reader, custom loops, timers simultaneously, with no manual teardown list to maintain.
Exercises
Write JavaScript code to solve each exercise. Run it and check your output.
Completed: 0 / 5 exercises
Knowledge Check
Question 1 of 4