Advanced Asynchronous Error Handling

Robust strategies for managing errors in asynchronous JavaScript

Introduction to Asynchronous Error Handling

Error handling in asynchronous JavaScript presents unique challenges compared to synchronous code. When operations are performed across multiple execution contexts, frames, or even network boundaries, traditional error handling patterns need to be adapted.

Think of asynchronous error handling like managing a delivery service across a city. In synchronous code, errors are like problems you encounter while personally delivering a package - you immediately know something went wrong. In asynchronous code, errors are like reports from different delivery drivers that might arrive at different times, through different channels, or might not arrive at all if communication breaks down.

graph TD A[Asynchronous Operation] --> B{Success?} B -->|Yes| C[Success Path] B -->|No| D[Error Path] D --> E{Error Type?} E -->|Operational| F[Retry/Recover] E -->|Programmer| G[Fix Bug] E -->|Network| H[Retry with Backoff] E -->|Timeout| I[Cancel/Abort] E -->|Validation| J[User Feedback]

The Evolution of Asynchronous Error Handling

JavaScript's approach to asynchronous error handling has evolved significantly over time, with each new pattern improving on the limitations of the previous ones.

The Callback Era

In the early days of asynchronous JavaScript, error handling relied on convention rather than language features. The "error-first callback" pattern became the standard approach:

Error-First Callbacks


function readFile(path, callback) {
  fs.readFile(path, (err, data) => {
    if (err) {
      callback(err);
      return;
    }
    
    callback(null, data);
  });
}

readFile('config.json', (err, data) => {
  if (err) {
    console.error('Error reading file:', err);
    return;
  }
  
  try {
    const config = JSON.parse(data);
    console.log('Config loaded:', config);
  } catch (parseError) {
    console.error('Error parsing JSON:', parseError);
  }
});
          

Challenges with callbacks:

The Promise Revolution

Promises introduced a more structured approach to error handling:

Promise Error Handling


function readFilePromise(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, (err, data) => {
      if (err) {
        reject(err);
        return;
      }
      resolve(data);
    });
  });
}

readFilePromise('config.json')
  .then(data => {
    const config = JSON.parse(data);
    console.log('Config loaded:', config);
  })
  .catch(err => {
    console.error('Operation failed:', err);
  });
          

Improvements with Promises:

Modern Async/Await Pattern

Async/await combines the readability of synchronous code with the power of promises:

Async/Await Error Handling


async function loadConfig() {
  try {
    const data = await readFilePromise('config.json');
    const config = JSON.parse(data);
    console.log('Config loaded:', config);
    return config;
  } catch (err) {
    console.error('Operation failed:', err);
    throw err; // Re-throw if needed
  }
}

// Usage
loadConfig().catch(err => {
  // Handle any errors not caught in loadConfig
  console.error('Configuration could not be loaded:', err);
});
          

Benefits of async/await:

Error Types and Classification

A key part of effective error handling is understanding the different types of errors that can occur and handling each appropriately.

Operational vs. Programmer Errors

A useful categorization is to distinguish between operational errors (expected problems that can occur during normal operation) and programmer errors (bugs that should be fixed).

classDiagram class Error { +name: string +message: string +stack: string } class OperationalError { Can be handled at runtime Examples: network timeout, file not found Strategy: recover or retry } class ProgrammerError { Bugs in code Examples: TypeError, ReferenceError Strategy: fix the code } Error <|-- OperationalError Error <|-- ProgrammerError

Common Asynchronous Error Types

Creating Custom Error Types


// Base application error class
class AppError extends Error {
  constructor(message, options = {}) {
    super(message);
    this.name = this.constructor.name;
    this.status = options.status || 500;
    this.code = options.code || 'INTERNAL_ERROR';
    this.retryable = options.retryable || false;
    this.cause = options.cause; // Store the original error
    
    // Capture stack trace
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

// Specific error types
class NetworkError extends AppError {
  constructor(message, options = {}) {
    super(message, { 
      status: options.status || 503, 
      code: options.code || 'NETWORK_ERROR',
      retryable: options.retryable !== false, // Network errors are retryable by default
      ...options 
    });
  }
}

class ValidationError extends AppError {
  constructor(message, fields = {}, options = {}) {
    super(message, { 
      status: options.status || 400, 
      code: options.code || 'VALIDATION_ERROR',
      retryable: false, // Validation errors are not retryable
      ...options 
    });
    this.fields = fields; // Store validation issues by field
  }
}

class TimeoutError extends AppError {
  constructor(message, options = {}) {
    super(message, { 
      status: options.status || 408, 
      code: options.code || 'TIMEOUT_ERROR',
      retryable: options.retryable !== false, // Timeouts are retryable by default
      ...options 
    });
    this.timeoutMs = options.timeoutMs;
  }
}

// Usage example
async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    
    if (!response.ok) {
      if (response.status === 404) {
        throw new ValidationError(
          `User with ID ${userId} not found`,
          { userId: 'not found' }
        );
      }
      if (response.status >= 500) {
        throw new NetworkError(
          `Server error while fetching user ${userId}`,
          { status: response.status }
        );
      }
      throw new AppError(
        `Failed to fetch user ${userId}`,
        { status: response.status }
      );
    }
    
    return await response.json();
  } catch (error) {
    if (error instanceof AppError) {
      // Our custom error, just rethrow it
      throw error;
    }
    
    // Handle native fetch errors
    if (error.name === 'AbortError') {
      throw new TimeoutError(
        `Request for user ${userId} timed out`,
        { timeoutMs: 5000, cause: error }
      );
    }
    
    // Unexpected error
    throw new AppError(
      `Unexpected error fetching user ${userId}`,
      { cause: error }
    );
  }
}
          

Strategies for Handling Asynchronous Errors

Let's explore practical strategies for dealing with different kinds of asynchronous errors.

Strategy 1: Retry with Exponential Backoff

For transient errors like network issues, retrying the operation can often succeed. Exponential backoff increases the delay between retries to avoid overwhelming the system.

Retry Function with Exponential Backoff


/**
 * Retries an async function with exponential backoff
 * @param {Function} fn - The async function to retry
 * @param {Object} options - Configuration options
 * @returns {Promise} - The result of the function
 */
async function retry(fn, options = {}) {
  const maxRetries = options.maxRetries || 3;
  const initialDelay = options.initialDelay || 1000; // 1 second
  const backoffFactor = options.backoffFactor || 2;
  const maxDelay = options.maxDelay || 30000; // 30 seconds
  const shouldRetry = options.shouldRetry || (error => error.retryable !== false);
  
  let lastError;
  
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      // Attempt the operation
      return await fn(attempt);
    } catch (error) {
      lastError = error;
      
      // Check if we should retry
      if (attempt >= maxRetries || !shouldRetry(error)) {
        break;
      }
      
      // Calculate delay with jitter to avoid thundering herd problem
      const delay = Math.min(
        maxDelay,
        initialDelay * Math.pow(backoffFactor, attempt) * (0.8 + Math.random() * 0.4)
      );
      
      console.log(`Retrying after ${delay}ms (attempt ${attempt + 1}/${maxRetries})...`);
      
      // Wait before retrying
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
  
  // All retries failed
  throw lastError;
}

// Usage example
async function fetchWithRetry(url) {
  return retry(
    async (attempt) => {
      console.log(`Attempt ${attempt + 1} to fetch ${url}`);
      const response = await fetch(url, { timeout: 3000 });
      
      if (!response.ok) {
        const error = new Error(`HTTP error ${response.status}`);
        error.status = response.status;
        error.retryable = response.status >= 500; // Only retry server errors
        throw error;
      }
      
      return response.json();
    },
    {
      maxRetries: 5,
      initialDelay: 1000,
      shouldRetry: error => 
        error.retryable === true || // Explicit retryable flag
        (error.status >= 500 && error.status < 600) || // Server errors
        error.message.includes('timeout') || // Timeout errors
        error.message.includes('network') // Network errors
    }
  );
}
          

Real-world application: Use retry patterns for:

Strategy 2: Circuit Breaker Pattern

The circuit breaker pattern prevents cascading failures by "tripping" after a threshold of failures and allowing limited "testing" requests to see if the system has recovered.

stateDiagram-v2 [*] --> Closed Closed --> Open : Failure threshold exceeded Open --> HalfOpen : Timeout/reset HalfOpen --> Closed : Success threshold met HalfOpen --> Open : New failure state Closed { [*] --> CheckFail CheckFail --> IncrementCount : Failure CheckFail --> ResetCount : Success IncrementCount --> [*] ResetCount --> [*] }

Circuit Breaker Implementation


class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 30000; // 30 seconds
    this.halfOpenSuccess = options.halfOpenSuccess || 1;
    this.excludeErrors = options.excludeErrors || [];
    
    this.state = 'CLOSED';
    this.failureCount = 0;
    this.successCount = 0;
    this.nextAttempt = Date.now();
    this.lastError = null;
  }
  
  async execute(fn) {
    // If circuit is open, check if we've waited long enough to try again
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttempt) {
        throw new Error(`Circuit breaker is open: ${this.lastError.message}`);
      }
      
      // Move to half-open state to test the waters
      this.state = 'HALF_OPEN';
      this.successCount = 0;
    }
    
    try {
      const result = await fn();
      
      this._handleSuccess();
      return result;
    } catch (error) {
      return this._handleError(error);
    }
  }
  
  _handleSuccess() {
    if (this.state === 'HALF_OPEN') {
      this.successCount++;
      
      if (this.successCount >= this.halfOpenSuccess) {
        // System seems to be working again
        this.state = 'CLOSED';
        this.failureCount = 0;
      }
    } else if (this.state === 'CLOSED') {
      // Reset failure count after a successful operation
      this.failureCount = 0;
    }
  }
  
  _handleError(error) {
    // Certain errors can be excluded from tripping the circuit
    if (this.excludeErrors.some(type => error instanceof type)) {
      throw error;
    }
    
    this.lastError = error;
    
    if (this.state === 'HALF_OPEN') {
      // Failed while testing the waters, back to open state
      this.state = 'OPEN';
      this.nextAttempt = Date.now() + this.resetTimeout;
    } else if (this.state === 'CLOSED') {
      this.failureCount++;
      
      if (this.failureCount >= this.failureThreshold) {
        // Too many failures, open the circuit
        this.state = 'OPEN';
        this.nextAttempt = Date.now() + this.resetTimeout;
      }
    }
    
    throw error;
  }
  
  // Reset the circuit breaker to closed state
  reset() {
    this.state = 'CLOSED';
    this.failureCount = 0;
    this.successCount = 0;
    this.nextAttempt = Date.now();
  }
}

// Usage example: protect database access
class DatabaseService {
  constructor() {
    this.circuitBreaker = new CircuitBreaker({
      failureThreshold: 3,
      resetTimeout: 10000, // 10 seconds
      excludeErrors: [ValidationError] // Don't trip on validation errors
    });
  }
  
  async query(sql, params) {
    return this.circuitBreaker.execute(async () => {
      // Database operation here
      return db.query(sql, params);
    });
  }
}

// Example with API calls
class ApiService {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
    this.breakers = {};
  }
  
  // Get or create circuit breaker for an endpoint
  getBreaker(endpoint) {
    if (!this.breakers[endpoint]) {
      this.breakers[endpoint] = new CircuitBreaker();
    }
    return this.breakers[endpoint];
  }
  
  async fetch(endpoint, options = {}) {
    const breaker = this.getBreaker(endpoint);
    
    return breaker.execute(async () => {
      const response = await fetch(`${this.baseUrl}/${endpoint}`, options);
      
      if (!response.ok) {
        const error = new Error(`API error: ${response.status}`);
        error.status = response.status;
        throw error;
      }
      
      return response.json();
    });
  }
}
          

Real-world application: Circuit breakers are valuable for:

Strategy 3: Timeouts and Cancellation

Never let async operations hang indefinitely. Implement timeouts and provide ways to cancel operations.

Implementing Timeouts with AbortController


/**
 * Adds a timeout to any promise
 * @param {Promise} promise - The promise to add timeout to
 * @param {number} timeoutMs - Timeout in milliseconds
 * @param {string} message - Error message if timeout occurs
 * @returns {Promise} - Promise with timeout
 */
function withTimeout(promise, timeoutMs, message = 'Operation timed out') {
  // Create an abort controller for the fetch operation
  const controller = new AbortController();
  const signal = controller.signal;
  
  // Create a timeout promise that rejects after specified time
  const timeoutPromise = new Promise((_, reject) => {
    const timeoutId = setTimeout(() => {
      controller.abort();
      reject(new TimeoutError(message, { timeoutMs }));
    }, timeoutMs);
    
    // Cleanup timeout if the main promise resolves/rejects
    signal.addEventListener('abort', () => clearTimeout(timeoutId));
  });
  
  // Return race between the main promise and timeout
  return Promise.race([
    promise,
    timeoutPromise
  ]);
}

// Example usage with fetch
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  const controller = new AbortController();
  
  // Add the abort signal to fetch options
  const fetchOptions = {
    ...options,
    signal: controller.signal
  };
  
  try {
    return await withTimeout(
      fetch(url, fetchOptions),
      timeoutMs,
      `Fetch to ${url} timed out after ${timeoutMs}ms`
    );
  } catch (error) {
    if (error.name === 'AbortError' || error instanceof TimeoutError) {
      // Handle timeout specific logic
      console.log(`Request to ${url} timed out`);
    }
    throw error;
  }
}

// Function that can be cancelled externally
function createCancellableOperation() {
  const controller = new AbortController();
  const signal = controller.signal;
  
  const operation = async () => {
    // Check for cancellation at key points
    const checkCancellation = () => {
      if (signal.aborted) {
        throw new Error('Operation was cancelled');
      }
    };
    
    try {
      // First step
      checkCancellation();
      await step1(signal);
      
      // Second step
      checkCancellation();
      await step2(signal);
      
      // Third step
      checkCancellation();
      return await step3(signal);
    } catch (error) {
      if (signal.aborted) {
        console.log('Operation cancelled:', signal.reason);
        throw new Error('Operation was cancelled: ' + signal.reason);
      }
      throw error;
    }
  };
  
  // Return both the operation promise and ways to control it
  return {
    execute: operation,
    cancel: (reason = 'User cancelled') => controller.abort(reason),
    signal
  };
}

// Usage example
async function loadUserData(userId) {
  const operation = createCancellableOperation();
  
  // Allow external cancellation
  document.getElementById('cancelButton').addEventListener('click', () => {
    operation.cancel('User clicked cancel');
  });
  
  try {
    return await withTimeout(
      operation.execute(),
      10000,
      'User data loading timed out'
    );
  } catch (error) {
    console.error('Failed to load user data:', error);
    throw error;
  }
}
          

Real-world application: Timeouts and cancellation are essential for:

Strategy 4: Graceful Degradation

When errors occur, provide fallback behavior instead of completely failing.

Implementing Fallbacks and Graceful Degradation


/**
 * Attempts to execute a function, falling back to alternatives if it fails
 * @param {Function[]} fns - Array of functions to try in order
 * @returns {Promise} - Result from the first successful function
 */
async function withFallbacks(fns) {
  const errors = [];
  
  for (let i = 0; i < fns.length; i++) {
    try {
      return await fns[i]();
    } catch (error) {
      errors.push(error);
      console.warn(`Fallback ${i + 1}/${fns.length} failed:`, error);
      // Continue to next fallback
    }
  }
  
  // If we get here, all fallbacks failed
  const error = new Error('All fallbacks failed');
  error.errors = errors; // Attach all errors for debugging
  throw error;
}

// Example: Loading user profile with fallbacks
async function loadUserProfile(userId) {
  return withFallbacks([
    // Primary: Get from API with full details
    () => fetch(`/api/users/${userId}/profile`).then(r => r.json()),
    
    // Fallback 1: Get from cache with full details
    () => cacheStorage.match(`/api/users/${userId}/profile`).then(r => r.json()),
    
    // Fallback 2: Get minimal profile from API
    () => fetch(`/api/users/${userId}/basic-profile`).then(r => r.json()),
    
    // Fallback 3: Get from local storage (might be outdated)
    () => JSON.parse(localStorage.getItem(`user_${userId}_profile`)),
    
    // Last resort: Return default profile
    () => ({ 
      id: userId, 
      name: 'Unknown User', 
      avatar: '/images/default-avatar.png',
      isPartialData: true
    })
  ]);
}

// Example: Feature with degraded functionality
class RecommendationEngine {
  async getRecommendations(userId) {
    try {
      // Try to get personalized recommendations
      return await this.getPersonalizedRecommendations(userId);
    } catch (error) {
      console.warn('Personalized recommendations failed:', error);
      
      try {
        // Fall back to category-based recommendations
        return await this.getCategoryRecommendations(userId);
      } catch (fallbackError) {
        console.warn('Category recommendations failed:', fallbackError);
        
        // Last resort: return popular items
        return this.getPopularItems();
      }
    }
  }
  
  async getPersonalizedRecommendations(userId) {
    // Complex ML-based recommendations
    // ...
  }
  
  async getCategoryRecommendations(userId) {
    // Simpler category-based recommendations
    // ...
  }
  
  getPopularItems() {
    // Just return a static list of popular items
    return [
      { id: 1, name: 'Popular Item 1' },
      { id: 2, name: 'Popular Item 2' },
      // ...
    ];
  }
}
          

Real-world application: Graceful degradation is valuable for:

Global Error Handling

In complex applications, it's important to have a global error handling strategy for uncaught asynchronous errors.

Global Unhandled Promise Rejection Handler


// Global handler for unhandled promise rejections
window.addEventListener('unhandledrejection', event => {
  const error = event.reason;
  
  console.error('Unhandled promise rejection:', error);
  
  // Log to monitoring service
  logErrorToMonitoring(error);
  
  // Show user-friendly error message if appropriate
  if (shouldShowUserError(error)) {
    showErrorNotification(
      'Something went wrong',
      error.userMessage || 'Please try again or contact support.'
    );
  }
  
  // Prevent the default browser behavior (console error)
  event.preventDefault();
});

// Error boundary for async React components
class AsyncErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    // Log to monitoring service
    logErrorToMonitoring(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ? (
        this.props.fallback(this.state.error)
      ) : (
        <div className="error-boundary">
          <h2>Something went wrong</h2>
          <p>Please try again or contact support.</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            Try Again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// Advanced error monitoring integration
function logErrorToMonitoring(error, additionalInfo = {}) {
  // Include important context with the error
  const errorContext = {
    url: window.location.href,
    userAgent: navigator.userAgent,
    timestamp: new Date().toISOString(),
    userId: currentUser?.id,
    ...additionalInfo
  };
  
  // Extract useful information from error
  const errorData = {
    message: error.message,
    stack: error.stack,
    name: error.name,
    code: error.code,
    status: error.status,
    // Capture nested error causes
    cause: error.cause ? {
      message: error.cause.message,
      stack: error.cause.stack,
      name: error.cause.name
    } : null
  };
  
  // Send to monitoring service (e.g., Sentry, LogRocket, etc.)
  monitoringService.captureException(error, {
    extra: {
      ...errorContext,
      errorData
    }
  });
  
  // Optional: also log to server-side error tracking
  fetch('/api/log-client-error', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ error: errorData, context: errorContext }),
    // Use keepalive to ensure the request completes even if page unloads
    keepalive: true
  }).catch(e => {
    // Just ignore errors from error logging itself
    console.error('Failed to send error log:', e);
  });
}
          

Error Handling in Different Environments

Error handling strategies differ slightly depending on the environment:

graph TB A[Asynchronous Errors] --> B[Browser] A --> C[Node.js] A --> D[React/Framework] B --> B1[window.onerror] B --> B2[unhandledrejection event] B --> B3[error event listeners] C --> C1[process.on('uncaughtException')] C --> C2[process.on('unhandledRejection')] C --> C3[domain module] D --> D1[Error boundaries] D --> D2[Global state management] D --> D3[Error middleware]

Best Practice: Always implement global error handlers as a safety net, but don't rely on them as your primary error handling strategy. Properly handle errors at the source whenever possible.

Debugging Asynchronous Errors

Asynchronous errors can be challenging to debug due to broken stack traces and timing issues. Here are some techniques to improve debugging:

Enhanced Error Capture Techniques


// Capture stack trace at creation time
function createPromiseWithEnhancedStack() {
  // Capture stack trace at promise creation time
  const creationStack = new Error().stack;
  
  return new Promise((resolve, reject) => {
    // Store the creation stack to add to any future rejections
    const enhancedReject = error => {
      if (error instanceof Error) {
        error.stack = `${error.stack}\n\nPromise created at:\n${creationStack.split('\n').slice(1).join('\n')}`;
      }
      reject(error);
    };
    
    // Your async logic here
    someAsyncOperation()
      .then(resolve)
      .catch(enhancedReject);
  });
}

// Helper to add creation stacks to all promises in development
if (process.env.NODE_ENV === 'development') {
  const originalPromise = Promise;
  
  globalThis.Promise = function PromiseWithStack(...args) {
    const creationStack = new Error().stack;
    
    const promise = new originalPromise(...args);
    
    const enhancedThen = promise.then;
    promise.then = function (onFulfilled, onRejected) {
      const newOnRejected = typeof onRejected === 'function'
        ? function (error) {
            try {
              return onRejected(error);
            } catch (e) {
              e.stack = `${e.stack}\n\nPromise created at:\n${creationStack.split('\n').slice(1).join('\n')}`;
              throw e;
            }
          }
        : error => {
            if (error instanceof Error) {
              error.stack = `${error.stack}\n\nPromise created at:\n${creationStack.split('\n').slice(1).join('\n')}`;
            }
            throw error;
          };
      
      return enhancedThen.call(promise, onFulfilled, newOnRejected);
    };
    
    return promise;
  };
  
  // Copy prototype and properties
  globalThis.Promise.prototype = originalPromise.prototype;
  Object.setPrototypeOf(globalThis.Promise, originalPromise);
}

// Logger with async correlation IDs
class AsyncLogger {
  constructor() {
    this.requestId = this._generateId();
    this.contextStack = [];
  }
  
  _generateId() {
    return Math.random().toString(36).substring(2, 15);
  }
  
  pushContext(context) {
    const id = this._generateId();
    this.contextStack.push({ id, ...context });
    return id;
  }
  
  popContext() {
    return this.contextStack.pop();
  }
  
  log(level, message, data = {}) {
    const currentContext = this.contextStack.length > 0 
      ? this.contextStack[this.contextStack.length - 1] 
      : {};
    
    console[level](`[${this.requestId}:${currentContext.id || 'root'}] ${message}`, {
      ...data,
      requestId: this.requestId,
      context: currentContext
    });
  }
  
  async withContext(context, fn) {
    const contextId = this.pushContext(context);
    
    try {
      this.log('info', `Starting: ${context.name || 'unnamed operation'}`);
      const result = await fn();
      this.log('info', `Completed: ${context.name || 'unnamed operation'}`);
      return result;
    } catch (error) {
      this.log('error', `Failed: ${context.name || 'unnamed operation'}`, { error });
      throw error;
    } finally {
      this.popContext();
    }
  }
  
  // Convenience methods
  info(message, data) { this.log('info', message, data); }
  warn(message, data) { this.log('warn', message, data); }
  error(message, data) { this.log('error', message, data); }
  debug(message, data) { this.log('debug', message, data); }
}

// Usage example
const logger = new AsyncLogger();

async function processOrder(orderId) {
  return logger.withContext({ name: 'processOrder', orderId }, async () => {
    logger.info('Processing order');
    
    // Verify payment
    await logger.withContext({ name: 'verifyPayment', orderId }, async () => {
      logger.debug('Checking payment status');
      // Payment verification logic
    });
    
    // Update inventory
    await logger.withContext({ name: 'updateInventory', orderId }, async () => {
      logger.debug('Updating inventory');
      // Inventory update logic
    });
    
    // Send confirmation
    await logger.withContext({ name: 'sendConfirmation', orderId }, async () => {
      logger.debug('Sending confirmation');
      // Send email logic
    });
    
    logger.info('Order processing complete');
  });
}
          

Modern Debugging Tools

Take advantage of modern debugging tools designed for asynchronous code:

Tip: During development, consider using console.trace() at key points in your asynchronous code to log stack traces. This can help identify where asynchronous operations are being initiated.

Best Practices and Guidelines

Let's summarize the key best practices for robust asynchronous error handling:

Complete Error Handling Example: Data Fetching Module


// errors.js - Error types
class AppError extends Error {
  constructor(message, options = {}) {
    super(message);
    this.name = this.constructor.name;
    this.status = options.status || 500;
    this.code = options.code || 'INTERNAL_ERROR';
    this.retryable = options.retryable ?? false;
    this.userMessage = options.userMessage || 'Something went wrong.';
    this.cause = options.cause;
    
    Error.captureStackTrace?.(this, this.constructor);
  }
}

class NetworkError extends AppError {
  constructor(message, options = {}) {
    super(message, { 
      code: 'NETWORK_ERROR',
      status: 503,
      retryable: true,
      userMessage: 'There was a network issue. Please check your connection.',
      ...options 
    });
  }
}

class TimeoutError extends AppError {
  constructor(message, options = {}) {
    super(message, { 
      code: 'TIMEOUT_ERROR',
      status: 408,
      retryable: true,
      userMessage: 'The operation timed out. Please try again.',
      ...options 
    });
  }
}

class NotFoundError extends AppError {
  constructor(message, options = {}) {
    super(message, { 
      code: 'NOT_FOUND',
      status: 404,
      retryable: false,
      userMessage: 'The requested resource was not found.',
      ...options 
    });
  }
}

// utils.js - Utility functions for error handling
async function retry(fn, options = {}) {
  const maxRetries = options.maxRetries || 3;
  const initialDelay = options.initialDelay || 1000;
  const backoffFactor = options.backoffFactor || 2;
  const maxDelay = options.maxDelay || 30000;
  const retryableErrors = options.retryableErrors || [NetworkError, TimeoutError];
  const shouldRetry = options.shouldRetry || (error => {
    return error.retryable === true || 
           retryableErrors.some(ErrorType => error instanceof ErrorType);
  });
  
  let lastError;
  
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn(attempt);
    } catch (error) {
      lastError = error;
      
      // Don't retry if this error isn't retryable
      if (attempt >= maxRetries || !shouldRetry(error)) {
        break;
      }
      
      // Calculate delay with jitter
      const delay = Math.min(
        maxDelay,
        initialDelay * Math.pow(backoffFactor, attempt) * (0.8 + Math.random() * 0.4)
      );
      
      // Wait before retrying
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
  
  throw lastError;
}

function withTimeout(promise, timeoutMs, message = 'Operation timed out') {
  // Create a timeout promise that rejects after specified time
  const timeoutPromise = new Promise((_, reject) => {
    const id = setTimeout(() => {
      reject(new TimeoutError(message, { timeoutMs }));
    }, timeoutMs);
    
    // Ensure the timeout is cleared if the main promise resolves
    promise.finally(() => clearTimeout(id)).catch(() => {});
  });
  
  // Race the main promise against the timeout
  return Promise.race([promise, timeoutPromise]);
}

// Fetch with error handling, retries, timeouts, and circuit breaker
class ApiClient {
  constructor(baseUrl, options = {}) {
    this.baseUrl = baseUrl;
    this.options = {
      timeout: 10000,
      retries: 3,
      ...options
    };
    
    // Set up circuit breakers for different endpoints
    this.breakers = {};
  }
  
  getCircuitBreaker(endpoint) {
    if (!this.breakers[endpoint]) {
      this.breakers[endpoint] = new CircuitBreaker({
        failureThreshold: 5,
        resetTimeout: 30000
      });
    }
    return this.breakers[endpoint];
  }
  
  async request(endpoint, options = {}) {
    const url = `${this.baseUrl}/${endpoint}`;
    const fetchOptions = {
      headers: {
        'Content-Type': 'application/json',
        ...this.options.headers,
        ...options.headers
      },
      ...options
    };
    
    // Get the circuit breaker for this endpoint
    const breaker = this.getCircuitBreaker(endpoint);
    
    // Execute with circuit breaker protection
    return breaker.execute(async () => {
      // Retry with exponential backoff
      return retry(
        async (attempt) => {
          // If not the first attempt, add retry header for tracking
          if (attempt > 0) {
            fetchOptions.headers['X-Retry-Count'] = attempt;
          }
          
          try {
            // Execute with timeout
            const response = await withTimeout(
              fetch(url, fetchOptions),
              options.timeout || this.options.timeout,
              `Request to ${url} timed out`
            );
            
            // Check for HTTP error status
            if (!response.ok) {
              let errorMessage;
              let errorData;
              
              // Try to extract error details from response
              try {
                errorData = await response.json();
                errorMessage = errorData.message || errorData.error || `HTTP error ${response.status}`;
              } catch (e) {
                // If we can't parse JSON, use the status text
                errorMessage = `HTTP error ${response.status} ${response.statusText}`;
              }
              
              // Create appropriate error type based on status
              switch (response.status) {
                case 404:
                  throw new NotFoundError(errorMessage, { 
                    status: response.status,
                    data: errorData
                  });
                case 400:
                case 422:
                  throw new AppError(errorMessage, { 
                    code: 'VALIDATION_ERROR',
                    status: response.status,
                    retryable: false,
                    data: errorData
                  });
                case 401:
                case 403:
                  throw new AppError(errorMessage, { 
                    code: 'AUTHORIZATION_ERROR',
                    status: response.status,
                    retryable: false,
                    data: errorData,
                    userMessage: 'You do not have permission to access this resource.'
                  });
                case 429:
                  throw new AppError(errorMessage, { 
                    code: 'RATE_LIMIT_ERROR',
                    status: response.status,
                    retryable: true,
                    data: errorData,
                    userMessage: 'Too many requests. Please try again later.'
                  });
                case 500:
                case 502:
                case 503:
                case 504:
                  throw new NetworkError(errorMessage, { 
                    status: response.status,
                    data: errorData
                  });
                default:
                  throw new AppError(errorMessage, { 
                    status: response.status,
                    retryable: response.status >= 500,
                    data: errorData
                  });
              }
            }
            
            // Process response based on content type
            const contentType = response.headers.get('Content-Type') || '';
            
            if (contentType.includes('application/json')) {
              return await response.json();
            } else if (contentType.includes('text/')) {
              return await response.text();
            } else {
              return await response.blob();
            }
          } catch (error) {
            // Convert native fetch errors to our custom error types
            if (error.name === 'AbortError' && !error.message.includes('timeout')) {
              throw new AppError('Request was aborted', {
                code: 'ABORTED',
                retryable: false,
                cause: error,
                userMessage: 'The operation was cancelled.'
              });
            } else if (error.message.includes('Failed to fetch') || 
                      error.message.includes('Network request failed')) {
              throw new NetworkError('Network request failed', {
                cause: error
              });
            }
            
            // Rethrow custom errors and unhandled errors
            throw error;
          }
        },
        {
          maxRetries: options.retries || this.options.retries,
          shouldRetry: error => error.retryable !== false
        }
      );
    });
  }
  
  // Convenience methods for common HTTP methods
  async get(endpoint, options = {}) {
    return this.request(endpoint, {
      method: 'GET',
      ...options
    });
  }
  
  async post(endpoint, data, options = {}) {
    return this.request(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
      ...options
    });
  }
  
  async put(endpoint, data, options = {}) {
    return this.request(endpoint, {
      method: 'PUT',
      body: JSON.stringify(data),
      ...options
    });
  }
  
  async delete(endpoint, options = {}) {
    return this.request(endpoint, {
      method: 'DELETE',
      ...options
    });
  }
}

// Usage example
const api = new ApiClient('https://api.example.com', {
  headers: {
    'Authorization': 'Bearer token123'
  },
  timeout: 5000,
  retries: 2
});

// In an async function
async function loadUser(userId) {
  try {
    return await api.get(`users/${userId}`);
  } catch (error) {
    if (error instanceof NotFoundError) {
      console.log(`User ${userId} not found`);
      return null;
    }
    
    // Let other errors propagate
    throw error;
  }
}
          

Practice Exercises

Exercise 1: Error Handling Framework

Build a small library of error handling utilities including:

  • Custom error classes for different error types
  • A retry function with exponential backoff
  • A timeout wrapper for promises
  • A circuit breaker implementation

Then use these tools to create a robust API client that handles errors gracefully.

Exercise 2: Fault Tolerance Testing

Create a testing framework that:

  • Simulates different types of failures (network errors, timeouts, server errors)
  • Tests your error handling strategies under various failure scenarios
  • Verifies that your application degrades gracefully
  • Ensures errors are properly logged and reported

Exercise 3: Async Workflow with Error Recovery

Implement a multi-step asynchronous workflow (e.g., a checkout process) that:

  • Handles errors at each step appropriately
  • Implements retry logic for transient failures
  • Provides partial success scenarios when possible
  • Logs errors for debugging and monitoring
  • Gives user-friendly error messages

Summary

Robust error handling is a critical aspect of asynchronous JavaScript programming. In this lecture, we've explored:

By implementing these strategies, you can build more resilient applications that gracefully handle failures, provide better user experiences, and are easier to debug and maintain.

Further Learning