Introduction to Async/Await Patterns
Now that we've explored the fundamentals of async functions and the await keyword, we'll focus on common patterns and techniques that solve real-world problems. These patterns represent proven approaches to handle various asynchronous scenarios effectively.
Understanding these patterns is crucial for building robust, maintainable applications that can handle the complexity of asynchronous operations, from API calls to database transactions, from parallel processing to error recovery.
Patterns)) Sequential Processing Series of operations Waterfall pattern Parallel Processing Promise.all pattern Concurrent batching Error Handling Retry pattern Fallback pattern Circuit breaker Flow Control Throttling Debouncing Timeouts Data Processing Pagination Streaming Caching
Sequential Processing Patterns
Waterfall Pattern
The waterfall pattern processes steps sequentially, where each step depends on the result of the previous step.
// Waterfall pattern for multi-step processing
async function processUserDashboard(userId) {
try {
// Step 1: Get user data
const userData = await getUserById(userId);
// Step 2: Get permissions using user data
const permissions = await getUserPermissions(userData.roleId);
// Step 3: Get authorized content based on permissions
const authorizedContent = await getAuthorizedContent(permissions);
// Step 4: Process dashboard data using all previous results
const dashboardData = await processDashboardData({
user: userData,
permissions: permissions,
content: authorizedContent
});
return dashboardData;
} catch (error) {
console.error('Dashboard processing failed:', error);
throw new Error(`Failed to process dashboard: ${error.message}`);
}
}
// Real-world example: E-commerce checkout process
async function processCheckout(cartId, userId) {
try {
// Step 1: Validate cart
const cart = await validateCart(cartId);
// Step 2: Check inventory
const inventoryCheck = await checkInventory(cart.items);
// Step 3: Get user's payment methods
const paymentMethods = await getPaymentMethods(userId);
// Step 4: Process payment
const paymentResult = await processPayment(cart.total, paymentMethods.default);
// Step 5: Create order
const order = await createOrder({
userId,
items: cart.items,
total: cart.total,
paymentId: paymentResult.id
});
// Step 6: Send confirmation
await sendOrderConfirmation(order, userId);
return {
success: true,
orderId: order.id,
total: cart.total,
estimatedDelivery: calculateDeliveryDate()
};
} catch (error) {
// Handle specific errors with custom responses
if (error.code === 'INVENTORY_ERROR') {
return { success: false, reason: 'inventory', message: error.message };
} else if (error.code === 'PAYMENT_ERROR') {
return { success: false, reason: 'payment', message: error.message };
}
return { success: false, reason: 'general', message: error.message };
}
}
Chain of Responsibility Pattern
This pattern passes a request through a chain of handlers, each deciding whether to process it or pass it to the next handler.
// Chain of responsibility with async/await
class AuthHandler {
constructor(next = null) {
this.next = next;
}
async handle(request) {
if (this.next) {
return await this.next.handle(request);
}
return null;
}
}
class TokenValidator extends AuthHandler {
async handle(request) {
if (!request.token) {
throw new Error('Missing authentication token');
}
// Validate token
const tokenData = await validateToken(request.token);
if (!tokenData.valid) {
throw new Error('Invalid authentication token');
}
request.userId = tokenData.userId;
// Pass to next handler
return await super.handle(request);
}
}
class RoleChecker extends AuthHandler {
constructor(requiredRole, next = null) {
super(next);
this.requiredRole = requiredRole;
}
async handle(request) {
// Get user roles
const userRoles = await getUserRoles(request.userId);
// Check if user has required role
if (!userRoles.includes(this.requiredRole)) {
throw new Error(`Access denied. Role ${this.requiredRole} required.`);
}
// Pass to next handler
return await super.handle(request);
}
}
class DataFetcher extends AuthHandler {
async handle(request) {
// Fetch the data
const data = await fetchSecureData(request.resourceId, request.userId);
// Add data to request
request.data = data;
// Pass to next handler
return await super.handle(request);
}
}
// Using the chain of responsibility
async function handleSecureRequest(token, resourceId) {
try {
// Build the chain
const chain = new TokenValidator(
new RoleChecker('admin',
new DataFetcher()
)
);
// Create request object
const request = { token, resourceId };
// Process through the chain
await chain.handle(request);
return {
success: true,
data: request.data
};
} catch (error) {
console.error('Request handling failed:', error);
return {
success: false,
error: error.message
};
}
}
Parallel Processing Patterns
Promise.all for Parallel Execution
The Promise.all pattern executes multiple asynchronous operations concurrently and waits for all to complete.
// Promise.all for parallel data fetching
async function loadDashboardData(userId) {
try {
// Start all data fetching operations in parallel
const [
userData,
userPosts,
friendsList,
notificationCount
] = await Promise.all([
fetchUserProfile(userId),
fetchUserPosts(userId),
fetchFriendsList(userId),
fetchNotificationCount(userId)
]);
// Combine results after all operations complete
return {
profile: userData,
posts: userPosts,
friends: friendsList,
notifications: notificationCount,
lastUpdated: new Date().toISOString()
};
} catch (error) {
console.error('Dashboard data loading failed:', error);
throw new Error(`Failed to load dashboard: ${error.message}`);
}
}
Controlled Concurrency with Batching
This pattern processes items in parallel, but limits the number of concurrent operations to avoid overwhelming resources.
// Controlled concurrency with batch processing
async function processItemsInBatches(items, batchSize = 3) {
const results = [];
// Process in batches
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
console.log(`Processing batch ${Math.floor(i / batchSize) + 1} of ${Math.ceil(items.length / batchSize)}`);
// Process current batch in parallel
const batchResults = await Promise.all(
batch.map(item => processItem(item))
);
// Add batch results to overall results
results.push(...batchResults);
}
return results;
}
Map-Reduce Pattern with Promise.all
This pattern processes items in parallel, then aggregates the results once all operations are complete.
// Map-Reduce pattern with Promise.all
async function analyzeUserActivity(userIds) {
try {
// MAP phase: Process each user concurrently
const userActivities = await Promise.all(
userIds.map(async userId => {
const logs = await fetchUserActivityLogs(userId);
// Process individual user data
return {
userId,
loginCount: logs.filter(log => log.type === 'login').length,
postCount: logs.filter(log => log.type === 'post').length,
commentCount: logs.filter(log => log.type === 'comment').length,
lastActive: new Date(Math.max(...logs.map(log => new Date(log.timestamp))))
};
})
);
// REDUCE phase: Combine and analyze all results
const totalUsers = userActivities.length;
const totalLogins = userActivities.reduce((sum, user) => sum + user.loginCount, 0);
const totalPosts = userActivities.reduce((sum, user) => sum + user.postCount, 0);
const totalComments = userActivities.reduce((sum, user) => sum + user.commentCount, 0);
// Find most active user
const mostActiveUser = userActivities.reduce((most, current) => {
const currentTotal = current.loginCount + current.postCount + current.commentCount;
const mostTotal = most.loginCount + most.postCount + most.commentCount;
return currentTotal > mostTotal ? current : most;
}, userActivities[0]);
// Calculate engagement metrics
return {
period: 'last 30 days',
userCount: totalUsers,
totalActivity: {
logins: totalLogins,
posts: totalPosts,
comments: totalComments
},
averagePerUser: {
logins: totalLogins / totalUsers,
posts: totalPosts / totalUsers,
comments: totalComments / totalUsers
},
mostActiveUser: {
userId: mostActiveUser.userId,
activity: mostActiveUser.loginCount + mostActiveUser.postCount + mostActiveUser.commentCount
}
};
} catch (error) {
console.error('User activity analysis failed:', error);
throw new Error(`Analysis failed: ${error.message}`);
}
}
Error Handling and Recovery Patterns
Retry Pattern
The retry pattern attempts an operation multiple times if it fails, with configurable delay between attempts.
Exceeded?} D -->|No| E[Increment Retry Count] E --> F[Wait with Backoff] F --> A D -->|Yes| G[Throw Error] style A fill:#bbdefb,stroke:#333,stroke-width:1px style B fill:#ffe0b2,stroke:#333,stroke-width:1px style C fill:#c8e6c9,stroke:#333,stroke-width:1px style D fill:#ffe0b2,stroke:#333,stroke-width:1px style G fill:#ffcdd2,stroke:#333,stroke-width:1px
// Retry pattern with exponential backoff
async function retryOperation(operation, options = {}) {
const {
maxRetries = 3,
baseDelay = 300,
maxDelay = 10000,
shouldRetry = () => true
} = options;
let retries = 0;
while (true) {
try {
return await operation();
} catch (error) {
// Check if we should retry this error
if (!shouldRetry(error)) {
throw error;
}
retries++;
if (retries > maxRetries) {
console.error(`Failed after ${maxRetries} retries:`, error);
throw error;
}
// Calculate delay with exponential backoff and jitter
const delay = Math.min(
baseDelay * Math.pow(2, retries),
maxDelay
) + Math.random() * 100;
console.warn(`Attempt ${retries} failed, retrying in ${delay.toFixed(0)}ms`);
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
Circuit Breaker Pattern
The circuit breaker pattern prevents repeated calls to a failing service, automatically stopping requests when a threshold of failures is reached.
// Circuit breaker pattern implementation
class CircuitBreaker {
constructor(operation, options = {}) {
this.operation = operation;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF-OPEN
// Configuration
this.failureThreshold = options.failureThreshold || 5;
this.resetTimeout = options.resetTimeout || 30000; // 30 seconds
this.halfOpenMaxCalls = options.halfOpenMaxCalls || 1;
// State tracking
this.failureCount = 0;
this.successCount = 0;
this.lastFailureTime = null;
this.halfOpenCallCount = 0;
// Optional callbacks
this.onStateChange = options.onStateChange || (() => {});
// Bind methods
this.execute = this.execute.bind(this);
this.reset = this.reset.bind(this);
}
async execute(...args) {
if (this.state === 'OPEN') {
// Check if reset timeout has elapsed
if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
this.halfOpenCircuit();
} else {
throw new Error('Circuit is open - service unavailable');
}
}
if (this.state === 'HALF-OPEN' && this.halfOpenCallCount >= this.halfOpenMaxCalls) {
throw new Error('Circuit is half-open and at call limit');
}
try {
// Track half-open calls
if (this.state === 'HALF-OPEN') {
this.halfOpenCallCount++;
}
// Execute the operation
const result = await this.operation(...args);
// Handle success
this.handleSuccess();
return result;
} catch (error) {
// Handle failure
this.handleFailure(error);
throw error;
}
}
handleSuccess() {
if (this.state === 'HALF-OPEN') {
this.closeCircuit();
} else {
// Reset failure count on success
this.failureCount = 0;
this.successCount++;
}
}
handleFailure(error) {
if (this.state === 'HALF-OPEN') {
this.openCircuit();
} else if (this.state === 'CLOSED') {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.openCircuit();
}
}
}
openCircuit() {
if (this.state !== 'OPEN') {
this.state = 'OPEN';
this.onStateChange('OPEN');
console.warn(`Circuit opened after ${this.failureCount} failures`);
}
}
halfOpenCircuit() {
this.state = 'HALF-OPEN';
this.halfOpenCallCount = 0;
this.onStateChange('HALF-OPEN');
console.warn('Circuit half-opened, allowing test request');
}
closeCircuit() {
this.state = 'CLOSED';
this.failureCount = 0;
this.successCount = 0;
this.onStateChange('CLOSED');
console.log('Circuit closed, service recovered');
}
reset() {
this.state = 'CLOSED';
this.failureCount = 0;
this.successCount = 0;
this.lastFailureTime = null;
this.halfOpenCallCount = 0;
this.onStateChange('CLOSED');
console.log('Circuit manually reset');
}
}
Integration Patterns with Other Libraries
Using Async/Await with React
Integrating async/await patterns with React components for data fetching and state management.
// Using async/await in React functional components with hooks
import React, { useState, useEffect } from 'react';
// Data fetching hook with async/await
function useDataFetching(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Define async function inside useEffect
async function fetchData() {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (error) {
setError(error.message);
} finally {
setLoading(false);
}
}
// Call the async function
fetchData();
// Optional cleanup function
return () => {
// Cancel any pending requests if needed
};
}, [url]); // Re-run when url changes
return { data, loading, error };
}
Using Async/Await with Node.js Streams
Combining async/await with Node.js streams for efficient data processing.
// Using async/await with Node.js streams
const fs = require('fs');
const { promisify } = require('util');
const stream = require('stream');
const pipeline = promisify(stream.pipeline);
async function processLargeFile(inputPath, outputPath, transformFn) {
try {
// Create readable and writable streams
const readStream = fs.createReadStream(inputPath, {
encoding: 'utf8',
highWaterMark: 64 * 1024 // 64KB chunks
});
const writeStream = fs.createWriteStream(outputPath);
// Create transform stream with async processing
const transformStream = new stream.Transform({
objectMode: true,
async transform(chunk, encoding, callback) {
try {
// Process chunk asynchronously
const transformedChunk = await transformFn(chunk.toString());
callback(null, transformedChunk);
} catch (error) {
callback(error);
}
}
});
// Use promisified pipeline
await pipeline(
readStream,
transformStream,
writeStream
);
console.log('File processing completed successfully');
return { success: true, inputPath, outputPath };
} catch (error) {
console.error('File processing failed:', error);
throw error;
}
}
Testing Async/Await Patterns
Unit Testing Async Functions
Patterns for effectively testing asynchronous functions with modern testing frameworks.
// Unit testing async functions with Jest
const { fetchUserData, processUserData } = require('../services/user-service');
// Mocking dependencies
jest.mock('../api/user-api', () => ({
getUser: jest.fn(),
getUserPermissions: jest.fn()
}));
const userApi = require('../api/user-api');
describe('User Service', () => {
// Reset mocks before each test
beforeEach(() => {
jest.clearAllMocks();
});
// Testing async function with async/await
test('fetchUserData should return user data when API call succeeds', async () => {
// Arrange
const mockUser = { id: '123', name: 'Test User' };
userApi.getUser.mockResolvedValue(mockUser);
// Act
const result = await fetchUserData('123');
// Assert
expect(result).toEqual(mockUser);
expect(userApi.getUser).toHaveBeenCalledWith('123');
});
// Testing error handling
test('fetchUserData should throw error when API call fails', async () => {
// Arrange
const errorMessage = 'API error';
userApi.getUser.mockRejectedValue(new Error(errorMessage));
// Act & Assert
await expect(fetchUserData('123')).rejects.toThrow(errorMessage);
expect(userApi.getUser).toHaveBeenCalledWith('123');
});
});
Async Patterns in Modern JavaScript Frameworks
React Hooks and Async Patterns
Modern patterns for handling asynchronous operations in React applications.
// Custom hook for async operations with loading state
import { useState, useEffect, useCallback } from 'react';
// Simple async state hook
function useAsync(asyncFunction, immediate = true) {
const [status, setStatus] = useState('idle');
const [value, setValue] = useState(null);
const [error, setError] = useState(null);
// Execute the async function
const execute = useCallback(async () => {
setStatus('pending');
setValue(null);
setError(null);
try {
const response = await asyncFunction();
setValue(response);
setStatus('success');
return response;
} catch (error) {
setError(error);
setStatus('error');
throw error;
}
}, [asyncFunction]);
// Run immediately if requested
useEffect(() => {
if (immediate) {
execute();
}
}, [execute, immediate]);
return { execute, status, value, error, isLoading: status === 'pending' };
}
Practice Exercises
Exercise 1: Implementing a Cache with Expiration
Create an async cache implementation that automatically expires items after a specified time.
Exercise 2: Building a Retry Mechanism
Implement a retry function with exponential backoff for fetching data from an unreliable API.
Exercise 3: Processing a Large Dataset
Create a function that processes a large array of items in smaller batches, with progress reporting.
Exercise 4: Creating a Rate-Limited API Client
Build an API client wrapper that enforces rate limits on requests to avoid overloading the server.
Exercise 5: Implementing Circuit Breaker for APIs
Create a circuit breaker implementation for a multi-service application to handle service outages gracefully.
Summary
In this lecture, we've explored a wide range of common async/await patterns that solve real-world challenges in JavaScript applications. These patterns help you write more robust, efficient, and maintainable asynchronous code.
- Sequential Processing Patterns: Waterfall pattern and chain of responsibility for executing steps in order
- Parallel Processing Patterns: Promise.all, controlled concurrency, and map-reduce for efficient parallel execution
- Error Handling Patterns: Retry, fallback, and circuit breaker patterns for robust error recovery
- Flow Control Patterns: Throttling, debouncing, and timeout patterns for controlling execution rate
- Data Patterns: Pagination, caching, and memoization for efficient data handling
- Integration Patterns: Using async/await with React, Node.js streams, and event emitters
By mastering these patterns, you can build applications that are more responsive, resilient, and maintainable. Remember that the right pattern depends on your specific requirements—there's no one-size-fits-all solution for asynchronous programming.