Common Async/Await Patterns

Mastering practical patterns for effective asynchronous JavaScript

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.

mindmap root((Async/Await
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.

Get User Data Get User Permissions Render Dashboard Waterfall Pattern • Each step depends on the previous step's result • Steps run sequentially, not in parallel • Clear linear flow with predictable order • Suitable when steps have dependencies
// 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.

sequenceDiagram participant Main as Main Function participant P1 as Promise 1 participant P2 as Promise 2 participant P3 as Promise 3 Main->>+P1: Start operation 1 Main->>+P2: Start operation 2 Main->>+P3: Start operation 3 P1-->>-Main: Complete P2-->>-Main: Complete P3-->>-Main: Complete Note over Main: All operations completed
// 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.

Time → Item 1 Item 2 Item 3 Batch 1 Item 4 Item 5 Item 6 Batch 2 Item 7 Item 8 Batch 3
// 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.

graph TD A[Attempt Operation] --> B{Success?} B -->|Yes| C[Return Result] B -->|No| D{Retry Count
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.

stateDiagram [*] --> Closed Closed --> Open: Failure threshold exceeded Open --> HalfOpen: Timeout period elapsed HalfOpen --> Closed: Success HalfOpen --> Open: Failure state Closed { [*] --> Success Success --> [*] Success --> Failure: Error occurs Failure --> Success: Success Failure --> [*]: Counter within threshold }
// 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.

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.

Further Reading