Skip to main content

Command Palette

Search for a command to run...

Async/Await Error Handling: Try-Catch Practices

Published
10 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Why Traditional Async Error Patterns Fail in 2025

The shift to distributed architectures, event-driven systems, and polyglot microservices has exposed fundamental weaknesses in legacy error handling approaches. Callback-based error handling created "callback hell" and made error propagation unpredictable. Early promise patterns with .catch() chains scattered error logic across codebases, making it impossible to enforce consistent error handling policies.

Even basic try-catch blocks around async/await code fail in subtle ways that only manifest under production load. Developers frequently forget that try-catch only captures errors within the immediate async function scope—errors in fire-and-forget operations, background tasks, or event handlers escape silently. In serverless environments like AWS Lambda or Cloudflare Workers, these unhandled rejections cause function timeouts, wasting compute resources and triggering cascading retries.

Modern systems demand error handling that integrates with distributed tracing (OpenTelemetry), structured logging, circuit breakers, and automated incident response. A try-catch block that logs to console.error provides zero value when debugging a failure that spans API Gateway, Lambda functions, SQS queues, and DynamoDB transactions.

The proliferation of TypeScript has added another dimension. Type-safe error handling requires patterns that preserve error types through async boundaries while maintaining compatibility with third-party libraries that throw untyped errors. Generic catch(error) blocks lose all type information, forcing developers to use type guards and runtime validation throughout error handling code.

Production-Grade Async/Await Error Handling Architecture

Modern async/await error handling requires a layered architecture that separates error detection, classification, enrichment, and response. This architecture must support both synchronous and asynchronous error flows while maintaining type safety and observability.

Structured Error Classes with Context Preservation

Define domain-specific error classes that carry structured context through async boundaries:

// Base error class with observability metadata
abstract class ApplicationError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode: number,
    public readonly context: Record<string, unknown> = {},
    public readonly isOperational: boolean = true
  ) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }

  toJSON() {
    return {
      name: this.name,
      message: this.message,
      code: this.code,
      statusCode: this.statusCode,
      context: this.context,
      stack: this.stack,
    };
  }
}

class ValidationError extends ApplicationError {
  constructor(message: string, context: Record<string, unknown> = {}) {
    super(message, 'VALIDATION_ERROR', 400, context);
  }
}

class ExternalServiceError extends ApplicationError {
  constructor(
    message: string,
    public readonly service: string,
    public readonly retryable: boolean,
    context: Record<string, unknown> = {}
  ) {
    super(message, 'EXTERNAL_SERVICE_ERROR', 502, context, retryable);
  }
}

class DatabaseError extends ApplicationError {
  constructor(
    message: string,
    public readonly operation: string,
    context: Record<string, unknown> = {}
  ) {
    super(message, 'DATABASE_ERROR', 500, context);
  }
}

Centralized Error Handler with Observability Integration

Implement a centralized error handler that integrates with your observability stack:

import { trace, context, SpanStatusCode } from '@opentelemetry/api';
import { logger } from './logger'; // Structured logger (Winston, Pino, etc.)

class ErrorHandler {
  private static instance: ErrorHandler;

  private constructor(
    private readonly logger: typeof logger,
    private readonly alertThreshold: number = 10
  ) {}

  static getInstance(): ErrorHandler {
    if (!ErrorHandler.instance) {
      ErrorHandler.instance = new ErrorHandler(logger);
    }
    return ErrorHandler.instance;
  }

  async handleError(error: Error, operationContext?: Record<string, unknown>): Promise<void> {
    const span = trace.getActiveSpan();

    // Enrich error with trace context
    const enrichedError = this.enrichError(error, operationContext);

    // Update active span if exists
    if (span) {
      span.recordException(enrichedError);
      span.setStatus({ code: SpanStatusCode.ERROR, message: enrichedError.message });
    }

    // Classify error severity
    const severity = this.classifyErrorSeverity(enrichedError);

    // Log with structured context
    this.logger[severity]({
      error: enrichedError instanceof ApplicationError 
        ? enrichedError.toJSON() 
        : { message: enrichedError.message, stack: enrichedError.stack },
      context: operationContext,
      traceId: span?.spanContext().traceId,
      spanId: span?.spanContext().spanId,
    });

    // Trigger alerts for critical errors
    if (severity === 'error' || severity === 'fatal') {
      await this.triggerAlert(enrichedError, operationContext);
    }
  }

  private enrichError(error: Error, context?: Record<string, unknown>): Error {
    if (error instanceof ApplicationError) {
      return error;
    }

    // Wrap unknown errors in ApplicationError
    return new ApplicationError(
      error.message || 'Unknown error occurred',
      'UNKNOWN_ERROR',
      500,
      { originalError: error.name, ...context },
      false
    );
  }

  private classifyErrorSeverity(error: Error): 'warn' | 'error' | 'fatal' {
    if (error instanceof ApplicationError) {
      if (!error.isOperational) return 'fatal';
      if (error.statusCode >= 500) return 'error';
      return 'warn';
    }
    return 'fatal'; // Unknown errors are critical
  }

  private async triggerAlert(error: Error, context?: Record<string, unknown>): Promise<void> {
    // Integration with PagerDuty, Opsgenie, or similar
    // Implementation depends on alerting infrastructure
  }
}

export const errorHandler = ErrorHandler.getInstance();

Async Function Wrapper with Automatic Error Handling

Create a higher-order function that wraps async operations with consistent error handling:

type AsyncFunction<T extends any[], R> = (...args: T) => Promise<R>;

interface WrapOptions {
  operationName: string;
  context?: Record<string, unknown>;
  retryable?: boolean;
  timeout?: number;
}

export function wrapAsync<T extends any[], R>(
  fn: AsyncFunction<T, R>,
  options: WrapOptions
): AsyncFunction<T, R> {
  return async (...args: T): Promise<R> => {
    const tracer = trace.getTracer('application');

    return await tracer.startActiveSpan(options.operationName, async (span) => {
      try {
        // Add timeout if specified
        if (options.timeout) {
          const timeoutPromise = new Promise<never>((_, reject) => {
            setTimeout(() => reject(new Error('Operation timeout')), options.timeout);
          });
          return await Promise.race([fn(...args), timeoutPromise]);
        }

        return await fn(...args);
      } catch (error) {
        await errorHandler.handleError(
          error instanceof Error ? error : new Error(String(error)),
          { ...options.context, args: args.map(arg => typeof arg) }
        );
        throw error; // Re-throw after handling
      } finally {
        span.end();
      }
    });
  };
}

// Usage example
const fetchUserData = wrapAsync(
  async (userId: string) => {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    if (!response.ok) {
      throw new ExternalServiceError(
        'Failed to fetch user data',
        'user-api',
        response.status >= 500,
        { userId, status: response.status }
      );
    }
    return response.json();
  },
  { operationName: 'fetch-user-data', timeout: 5000 }
);

Parallel Operations with Granular Error Handling

Handle multiple concurrent async operations with fine-grained error control:

interface OperationResult<T> {
  success: boolean;
  data?: T;
  error?: Error;
  operationId: string;
}

async function executeParallelWithErrorHandling<T>(
  operations: Array<{ id: string; fn: () => Promise<T> }>,
  options: { failFast?: boolean; continueOnError?: boolean } = {}
): Promise<OperationResult<T>[]> {
  const { failFast = false, continueOnError = true } = options;

  if (failFast) {
    // Fail immediately on first error
    const results = await Promise.all(
      operations.map(async (op) => {
        try {
          const data = await op.fn();
          return { success: true, data, operationId: op.id } as OperationResult<T>;
        } catch (error) {
          await errorHandler.handleError(
            error instanceof Error ? error : new Error(String(error)),
            { operationId: op.id }
          );
          throw error;
        }
      })
    );
    return results;
  }

  // Collect all results, including failures
  const results = await Promise.allSettled(
    operations.map(async (op) => {
      try {
        const data = await op.fn();
        return { success: true, data, operationId: op.id } as OperationResult<T>;
      } catch (error) {
        await errorHandler.handleError(
          error instanceof Error ? error : new Error(String(error)),
          { operationId: op.id }
        );
        return {
          success: false,
          error: error instanceof Error ? error : new Error(String(error)),
          operationId: op.id,
        } as OperationResult<T>;
      }
    })
  );

  const processedResults = results.map((result) =>
    result.status === 'fulfilled' ? result.value : result.reason
  );

  const failureCount = processedResults.filter((r) => !r.success).length;

  if (!continueOnError && failureCount > 0) {
    throw new ApplicationError(
      `${failureCount} operations failed`,
      'PARALLEL_OPERATION_FAILURE',
      500,
      { failureCount, totalOperations: operations.length }
    );
  }

  return processedResults;
}

Common Pitfalls and Edge Cases

Unhandled Promise Rejections in Event Handlers: Event emitters and message queue handlers often execute async callbacks without proper error boundaries. Always wrap event handlers with try-catch or use the wrapper pattern.

Silent Failures in Fire-and-Forget Operations: Background tasks initiated with void asyncFunction() or without awaiting lose error context. Use explicit error handling or task queues with dead-letter queues.

Error Loss in Promise.all: When one promise rejects in Promise.all, other promises continue executing but their errors are lost. Use Promise.allSettled for operations that should complete independently.

Type Erasure in Catch Blocks: TypeScript's catch(error) receives unknown type. Always validate error types before accessing properties:

try {
  await riskyOperation();
} catch (error) {
  if (error instanceof ApplicationError) {
    // Type-safe access to ApplicationError properties
    logger.error({ code: error.code, context: error.context });
  } else if (error instanceof Error) {
    logger.error({ message: error.message });
  } else {
    logger.error({ error: String(error) });
  }
}

Resource Leaks in Error Paths: Database connections, file handles, and HTTP clients must be cleaned up even when errors occur. Use finally blocks or resource management patterns:

async function processWithResources() {
  const connection = await pool.connect();
  try {
    await connection.query('BEGIN');
    await performOperations(connection);
    await connection.query('COMMIT');
  } catch (error) {
    await connection.query('ROLLBACK');
    throw error;
  } finally {
    connection.release();
  }
}

Retry Logic Without Backoff: Immediate retries on external service failures amplify load during outages. Implement exponential backoff with jitter:

async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  baseDelay: number = 1000
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;

      if (error instanceof ExternalServiceError && !error.retryable) {
        throw error;
      }

      const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
  throw new Error('Retry logic failed unexpectedly');
}

Best Practices Checklist

Establish Error Classification: Define clear error categories (validation, external service, database, business logic) with consistent status codes and retry policies.

Implement Centralized Error Handling: Route all errors through a single handler that integrates with logging, tracing, and alerting infrastructure.

Preserve Error Context: Include operation metadata, user context, and trace IDs in error objects for effective debugging.

Use Type-Safe Error Handling: Leverage TypeScript discriminated unions or custom error classes to maintain type safety through async boundaries.

Handle Parallel Operations Explicitly: Choose between Promise.all, Promise.allSettled, or Promise.race based on failure tolerance requirements.

Implement Circuit Breakers: Protect external services with circuit breakers that prevent cascading failures during outages.

Monitor Error Rates as SLIs: Track error rates, error types, and recovery times as key service health indicators.

Test Error Paths: Write integration tests that verify error handling behavior under various failure scenarios.

Document Error Contracts: Maintain clear documentation of error types, status codes, and expected client behavior for each API endpoint.

Implement Graceful Degradation: Design systems to continue operating with reduced functionality when non-critical services fail.

Frequently Asked Questions

What is the difference between operational and programmer errors in async/await error handling?

Operational errors are expected runtime failures like network timeouts, invalid user input, or database connection failures. These should be handled gracefully with retries, fallbacks, or user-friendly error messages. Programmer errors are bugs like null pointer exceptions, type errors, or logic mistakes that indicate code defects requiring fixes. Modern error handling architectures distinguish these through the isOperational flag, routing operational errors to recovery logic and programmer errors to alerting systems.

How does async/await error handling work with TypeScript strict mode in 2025?

TypeScript 5.x with strict mode treats caught errors as unknown type, requiring explicit type validation before accessing error properties. Production code should use type guards (error instanceof CustomError) or validation libraries like Zod to safely narrow error types. This prevents runtime errors from accessing undefined properties on unexpected error objects.

What is the best way to handle errors in Promise.all versus Promise.allSettled?

Use Promise.all when all operations must succeed for the overall operation to be valid—it fails fast on the first rejection. Use Promise.allSettled when operations are independent and you need results from all attempts regardless of individual failures. For critical operations with partial failure tolerance, use Promise.allSettled and implement custom logic to determine if enough operations succeeded.

When should you avoid using try-catch with async/await?

Avoid try-catch for expected control flow (use explicit result types instead), for wrapping every single async call (use centralized error boundaries), and for errors that should propagate to global handlers (like unrecoverable system failures). Try-catch is appropriate for operations requiring specific error handling logic, resource cleanup, or error transformation.

How do you implement error handling for serverless functions in 2025?

Serverless functions require error handling that completes within function timeout limits, integrates with platform-specific logging (CloudWatch, Cloud Logging), and properly signals failure to trigger retries. Implement a lightweight error handler that logs structured errors, updates function metrics, and returns appropriate HTTP status codes. Use dead-letter queues for async event processing to capture failed invocations.

What are error boundaries for async operations in Node.js?

Error boundaries are architectural patterns that isolate error handling to specific application layers. In Express/Fastify applications, use error-handling middleware as boundaries. In event-driven systems, wrap event handlers with error boundaries that prevent individual event failures from crashing the process. In microservices, implement service mesh error boundaries that handle inter-service communication failures.

How do you scale error handling across distributed microservices?

Implement standardized error schemas across services, use distributed tracing to correlate errors across service boundaries, centralize error aggregation in observability platforms, and establish service-level error budgets. Deploy sidecar proxies or service meshes that handle cross-cutting error concerns like retries, circuit breaking, and timeout enforcement consistently across all services.

Conclusion

Effective async/await error handling in 2025 requires moving beyond basic try-catch blocks to implement comprehensive error management architectures. By establishing structured error classes, centralizing error handling logic, integrating with observability platforms, and implementing type-safe error patterns, development teams can build resilient distributed systems that gracefully handle failures at scale.

Start by auditing your current error handling patterns, identifying unhandled promise rejections and inconsistent error responses. Implement centralized error handling with observability integration, then progressively refactor critical paths to use structured error classes and the wrapper patterns demonstrated above. Establish error rate monitoring as a key SLI and use error budgets to drive reliability improvements. For deeper exploration, investigate circuit breaker patterns, chaos engineering practices for error scenario testing, and advanced observability techniques like error sampling and anomaly detection.

Async/Await Error Handling: Try-Catch Practices