Advanced JavaScript (ES6+)

Advanced Async Patterns

13 min Lesson 19 of 40

Advanced Async Patterns

Beyond basic async/await, JavaScript provides powerful patterns for handling complex asynchronous scenarios. In this lesson, we'll explore async iteration, generators, parallel execution strategies, and advanced error handling techniques.

Async Iteration with for await...of

The for await...of loop allows you to iterate over async iterables, processing promises sequentially:

// Async iterable example async function* generateNumbers() { for (let i = 1; i <= 5; i++) { await new Promise(resolve => setTimeout(resolve, 500)); yield i; } } // Using for await...of async function processNumbers() { for await (const num of generateNumbers()) { console.log(num); // 1, 2, 3, 4, 5 (one every 500ms) } } processNumbers(); // Real-world example: Processing API pages async function* fetchAllPages(baseUrl) { let page = 1; let hasMore = true; while (hasMore) { const response = await fetch(`${baseUrl}?page=${page}`); const data = await response.json(); yield data.items; hasMore = data.hasNextPage; page++; } } // Usage async function loadAllItems() { for await (const items of fetchAllPages("/api/products")) { console.log(`Processing ${items.length} items`); items.forEach(item => console.log(item.name)); } }
Key Point: for await...of is perfect for processing streams of data, paginated APIs, or any scenario where you need to handle promises one at a time in sequence.

Async Generators

Async generators combine the power of generators with async/await, allowing you to yield promises:

// Simple async generator async function* countDown(start) { for (let i = start; i >= 0; i--) { await new Promise(resolve => setTimeout(resolve, 1000)); yield i; } } // Usage async function runCountdown() { const countdown = countDown(3); for await (const num of countdown) { console.log(num); // 3, 2, 1, 0 (one per second) } console.log("Done!"); } // Advanced: Async generator with input async function* dataProcessor() { while (true) { const input = yield; // Receive input if (input === "stop") break; // Process data asynchronously const result = await processData(input); yield result; // Send result } } async function processData(data) { await new Promise(resolve => setTimeout(resolve, 100)); return data.toUpperCase(); }

Parallel vs Sequential Execution

Understanding when to use parallel vs sequential execution is crucial for performance:

// Sequential execution (slower) async function fetchDataSequential() { console.time("Sequential"); const user = await fetch("/api/user"); const posts = await fetch("/api/posts"); const comments = await fetch("/api/comments"); console.timeEnd("Sequential"); // ~3 seconds if each takes 1s return { user: await user.json(), posts: await posts.json(), comments: await comments.json() }; } // Parallel execution (faster) async function fetchDataParallel() { console.time("Parallel"); // All requests start at once const [user, posts, comments] = await Promise.all([ fetch("/api/user").then(r => r.json()), fetch("/api/posts").then(r => r.json()), fetch("/api/comments").then(r => r.json()) ]); console.timeEnd("Parallel"); // ~1 second (max of all requests) return { user, posts, comments }; } // Mixed: Some sequential, some parallel async function fetchDataMixed() { // Step 1: Get user (required first) const user = await fetch("/api/user").then(r => r.json()); // Step 2: Get posts and comments in parallel (both need user.id) const [posts, comments] = await Promise.all([ fetch(`/api/posts?userId=${user.id}`).then(r => r.json()), fetch(`/api/comments?userId=${user.id}`).then(r => r.json()) ]); return { user, posts, comments }; }
Performance Tip: Use Promise.all() for independent operations, but be aware that if any promise rejects, the entire operation fails. Use Promise.allSettled() if you want all operations to complete regardless of individual failures.

Throttling Async Operations

Control the rate of async operations to avoid overwhelming APIs or resources:

// Throttle function: limit concurrent operations async function throttlePromises(tasks, limit) { const results = []; const executing = []; for (const task of tasks) { const promise = task().then(result => { executing.splice(executing.indexOf(promise), 1); return result; }); results.push(promise); executing.push(promise); if (executing.length >= limit) { await Promise.race(executing); } } return Promise.all(results); } // Usage const tasks = Array.from({ length: 10 }, (_, i) => { return async () => { console.log(`Starting task ${i + 1}`); await new Promise(resolve => setTimeout(resolve, 1000)); console.log(`Completed task ${i + 1}`); return i + 1; }; }); // Run max 3 tasks at a time const results = await throttlePromises(tasks, 3); console.log("All results:", results);

Debouncing Async Operations

Prevent excessive async calls by debouncing (waiting until activity stops):

// Async debounce function function debounceAsync(func, delay) { let timeoutId; return function(...args) { clearTimeout(timeoutId); return new Promise((resolve, reject) => { timeoutId = setTimeout(async () => { try { const result = await func.apply(this, args); resolve(result); } catch (error) { reject(error); } }, delay); }); }; } // Usage: Search API with debounce const searchAPI = debounceAsync(async (query) => { console.log(`Searching for: ${query}`); const response = await fetch(`/api/search?q=${query}`); return await response.json(); }, 500); // User typing triggers multiple calls, but only last one executes searchInput.addEventListener("input", async (e) => { try { const results = await searchAPI(e.target.value); displayResults(results); } catch (error) { console.error("Search failed:", error); } });

Retry Patterns

Automatically retry failed async operations with exponential backoff:

// Retry with exponential backoff async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { if (attempt === maxRetries) { throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`); } const delay = baseDelay * Math.pow(2, attempt - 1); console.log(`Attempt ${attempt} failed. Retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } } } // Usage async function fetchUnreliableAPI() { const response = await fetch("/api/unreliable"); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return await response.json(); } try { const data = await retryWithBackoff(fetchUnreliableAPI, 3, 1000); console.log("Success:", data); } catch (error) { console.error("Failed after all retries:", error.message); } // Advanced: Retry with custom conditions async function retryWithCondition(fn, options = {}) { const { maxRetries = 3, baseDelay = 1000, shouldRetry = () => true, onRetry = () => {} } = options; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { if (attempt === maxRetries || !shouldRetry(error, attempt)) { throw error; } const delay = baseDelay * Math.pow(2, attempt - 1); onRetry(error, attempt, delay); await new Promise(resolve => setTimeout(resolve, delay)); } } } // Usage with custom retry conditions const data = await retryWithCondition( () => fetchUnreliableAPI(), { maxRetries: 5, baseDelay: 500, shouldRetry: (error, attempt) => { // Only retry on network errors, not 4xx client errors return !error.message.includes("HTTP 4"); }, onRetry: (error, attempt, delay) => { console.log(`Retry ${attempt}: ${error.message} (waiting ${delay}ms)`); } } );
Warning: Be careful with retry logic on API calls that modify data (POST, PUT, DELETE). Retrying these operations can lead to duplicate actions or inconsistent state.

Timeout Handling

Add timeouts to async operations to prevent hanging:

// Create timeout promise function timeout(ms, message = "Operation timed out") { return new Promise((_, reject) => { setTimeout(() => reject(new Error(message)), ms); }); } // Race between operation and timeout async function withTimeout(promise, ms) { return Promise.race([ promise, timeout(ms) ]); } // Usage try { const data = await withTimeout( fetch("/api/slow-endpoint").then(r => r.json()), 5000 ); console.log("Success:", data); } catch (error) { if (error.message.includes("timed out")) { console.error("Request took too long"); } else { console.error("Request failed:", error); } } // Advanced: Timeout with AbortController async function fetchWithTimeout(url, ms = 5000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), ms); try { const response = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json(); } catch (error) { if (error.name === "AbortError") { throw new Error(`Request timeout after ${ms}ms`); } throw error; } }

Real-World Pattern: Queue Processing

// Async queue processor class AsyncQueue { constructor(concurrency = 1) { this.concurrency = concurrency; this.running = 0; this.queue = []; } async add(task) { return new Promise((resolve, reject) => { this.queue.push({ task, resolve, reject }); this.process(); }); } async process() { if (this.running >= this.concurrency || this.queue.length === 0) { return; } this.running++; const { task, resolve, reject } = this.queue.shift(); try { const result = await task(); resolve(result); } catch (error) { reject(error); } finally { this.running--; this.process(); } } async waitForAll() { while (this.running > 0 || this.queue.length > 0) { await new Promise(resolve => setTimeout(resolve, 10)); } } } // Usage const queue = new AsyncQueue(3); // Max 3 concurrent operations const tasks = [ () => fetch("/api/user/1").then(r => r.json()), () => fetch("/api/user/2").then(r => r.json()), () => fetch("/api/user/3").then(r => r.json()), () => fetch("/api/user/4").then(r => r.json()), () => fetch("/api/user/5").then(r => r.json()) ]; // Add all tasks to queue const results = await Promise.all( tasks.map(task => queue.add(task)) ); console.log("All users fetched:", results);

Practice Exercise:

Task: Create a function that fetches data from multiple URLs with rate limiting, retry logic, and timeout handling.

// Your task: Implement this function async function fetchMultipleWithControls(urls, options = {}) { // Options: // - maxConcurrent: Max parallel requests // - timeout: Request timeout in ms // - maxRetries: Max retry attempts // - retryDelay: Base delay between retries // Return array of results with format: // { url, success: true, data } or { url, success: false, error } }

Solution:

async function fetchMultipleWithControls(urls, options = {}) { const { maxConcurrent = 3, timeout: timeoutMs = 5000, maxRetries = 2, retryDelay = 1000 } = options; async function fetchWithRetry(url) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); return { url, success: true, data }; } catch (error) { if (attempt === maxRetries) { return { url, success: false, error: error.message }; } const delay = retryDelay * attempt; console.log(`${url}: Retry ${attempt} after ${delay}ms`); await new Promise(resolve => setTimeout(resolve, delay)); } } } // Process with concurrency limit const results = []; const executing = []; for (const url of urls) { const promise = fetchWithRetry(url).then(result => { executing.splice(executing.indexOf(promise), 1); return result; }); results.push(promise); executing.push(promise); if (executing.length >= maxConcurrent) { await Promise.race(executing); } } return Promise.all(results); } // Usage const urls = [ "https://api.example.com/user/1", "https://api.example.com/user/2", "https://api.example.com/user/3", "https://api.example.com/user/4", "https://api.example.com/user/5" ]; const results = await fetchMultipleWithControls(urls, { maxConcurrent: 2, timeout: 3000, maxRetries: 3, retryDelay: 500 }); results.forEach(result => { if (result.success) { console.log(`✓ ${result.url}:`, result.data); } else { console.log(`✗ ${result.url}:`, result.error); } });

Summary

In this lesson, you learned:

  • for await...of for sequential async iteration
  • Async generators for yielding promises
  • Parallel vs sequential execution strategies
  • Throttling to limit concurrent operations
  • Debouncing to reduce excessive async calls
  • Retry patterns with exponential backoff
  • Timeout handling to prevent hanging operations
  • Queue processing for controlled concurrency
Next Up: In the next lesson, we'll dive deep into the JavaScript event loop to understand how asynchronous code really works under the hood!