Best Practices
intermediate
#error-handling#debugging#robust-code

Error Handling Strategies in Modern JavaScript Applications

Build robust applications with proper error handling. Learn try-catch, error boundaries, and advanced error management techniques.

June 8, 2025
23 min read
Share this article:

Error handling is one of the most critical aspects of building robust JavaScript applications. Poor error handling can lead to crashes, data loss, security vulnerabilities, and frustrated users. In this comprehensive guide, we'll explore modern error handling strategies that will help you build resilient applications that gracefully handle unexpected situations.

Understanding JavaScript Errors

Before diving into handling strategies, let's understand the types of errors you'll encounter in JavaScript:

javascript
// 1. Syntax Errors (caught at parse time)
// console.log("Hello World" // Missing closing parenthesis

// 2. Reference Errors (accessing undefined variables)
try {
  console.log(undefinedVariable)
} catch (error) {
  console.log(error.name) // "ReferenceError"
}

// 3. Type Errors (wrong data type operations)
try {
  null.someMethod()
} catch (error) {
  console.log(error.name) // "TypeError"
}

// 4. Range Errors (values outside valid range)
try {
  new Array(-1)
} catch (error) {
  console.log(error.name) // "RangeError"
}

// 5. Custom Errors
class ValidationError extends Error {
  constructor(message, field) {
    super(message)
    this.name = 'ValidationError'
    this.field = field
  }
}

Try-Catch Fundamentals

Basic Try-Catch Structure

javascript
function divideNumbers(a, b) {
  try {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new TypeError('Both arguments must be numbers')
    }

    if (b === 0) {
      throw new Error('Division by zero is not allowed')
    }

    return a / b
  } catch (error) {
    console.error('Error in divideNumbers:', error.message)

    // Handle different error types
    if (error instanceof TypeError) {
      return { error: 'Invalid input types' }
    } else if (error.message.includes('Division by zero')) {
      return { error: 'Cannot divide by zero' }
    }

    return { error: 'Unknown error occurred' }
  } finally {
    // This block always executes
    console.log('Division operation completed')
  }
}

// Usage
console.log(divideNumbers(10, 2)) // 5
console.log(divideNumbers(10, 0)) // { error: 'Cannot divide by zero' }
console.log(divideNumbers('10', 2)) // { error: 'Invalid input types' }

Advanced Try-Catch Patterns

javascript
// Nested try-catch for granular error handling
function processUserData(userData) {
  try {
    // Validate user data
    try {
      validateUserData(userData)
    } catch (validationError) {
      throw new ValidationError(`Invalid user data: ${validationError.message}`)
    }

    // Process the data
    try {
      return transformUserData(userData)
    } catch (transformError) {
      throw new Error(`Data transformation failed: ${transformError.message}`)
    }
  } catch (error) {
    // Log the error for debugging
    console.error('User data processing failed:', error)

    // Return user-friendly error
    if (error instanceof ValidationError) {
      return { success: false, error: 'Please check your input data' }
    }

    return { success: false, error: 'Processing failed. Please try again.' }
  }
}

// Error recovery pattern
function robustApiCall(url, options = {}) {
  const maxRetries = options.maxRetries || 3
  const retryDelay = options.retryDelay || 1000

  async function attemptCall(attempt = 1) {
    try {
      const response = await fetch(url, options)

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }

      return await response.json()
    } catch (error) {
      console.warn(`Attempt ${attempt} failed:`, error.message)

      if (attempt >= maxRetries) {
        throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`)
      }

      // Wait before retrying
      await new Promise((resolve) => setTimeout(resolve, retryDelay * attempt))
      return attemptCall(attempt + 1)
    }
  }

  return attemptCall()
}

Async/Await Error Handling

Basic Async Error Handling

javascript
// Basic async/await error handling
async function fetchUserProfile(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`)

    if (!response.ok) {
      throw new Error(`Failed to fetch user: ${response.status}`)
    }

    const user = await response.json()
    return { success: true, data: user }
  } catch (error) {
    console.error('Error fetching user profile:', error)
    return { success: false, error: error.message }
  }
}

// Multiple async operations
async function loadUserDashboard(userId) {
  try {
    // Run operations in parallel
    const [user, posts, notifications] = await Promise.all([
      fetchUserProfile(userId),
      fetchUserPosts(userId),
      fetchUserNotifications(userId),
    ])

    // Check if any operation failed
    if (!user.success) throw new Error('Failed to load user profile')
    if (!posts.success) throw new Error('Failed to load user posts')
    if (!notifications.success) throw new Error('Failed to load notifications')

    return {
      success: true,
      data: {
        user: user.data,
        posts: posts.data,
        notifications: notifications.data,
      },
    }
  } catch (error) {
    console.error('Dashboard loading failed:', error)
    return { success: false, error: error.message }
  }
}

Advanced Async Error Patterns

javascript
// Error boundary for async operations
class AsyncErrorBoundary {
  constructor() {
    this.errors = []
    this.onError = null
  }

  async execute(asyncFunction, context = 'Unknown') {
    try {
      return await asyncFunction()
    } catch (error) {
      const errorInfo = {
        error,
        context,
        timestamp: new Date().toISOString(),
        stack: error.stack,
      }

      this.errors.push(errorInfo)

      if (this.onError) {
        this.onError(errorInfo)
      }

      throw error
    }
  }

  getErrors() {
    return this.errors
  }

  clearErrors() {
    this.errors = []
  }
}

// Usage
const errorBoundary = new AsyncErrorBoundary()
errorBoundary.onError = (errorInfo) => {
  // Send to logging service
  console.error('Async error caught:', errorInfo)
}

// Timeout wrapper for async operations
function withTimeout(
  promise,
  timeoutMs,
  timeoutMessage = 'Operation timed out',
) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs)
  })

  return Promise.race([promise, timeoutPromise])
}

// Circuit breaker pattern
class CircuitBreaker {
  constructor(threshold = 5, timeout = 60000) {
    this.threshold = threshold
    this.timeout = timeout
    this.failureCount = 0
    this.lastFailureTime = null
    this.state = 'CLOSED' // CLOSED, OPEN, HALF_OPEN
  }

  async execute(operation) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime > this.timeout) {
        this.state = 'HALF_OPEN'
      } else {
        throw new Error('Circuit breaker is OPEN')
      }
    }

    try {
      const result = await operation()
      this.onSuccess()
      return result
    } catch (error) {
      this.onFailure()
      throw error
    }
  }

  onSuccess() {
    this.failureCount = 0
    this.state = 'CLOSED'
  }

  onFailure() {
    this.failureCount++
    this.lastFailureTime = Date.now()

    if (this.failureCount >= this.threshold) {
      this.state = 'OPEN'
    }
  }
}

Promise Error Handling

Promise.catch() and Error Propagation

javascript
// Basic promise error handling
function fetchData(url) {
  return fetch(url)
    .then((response) => {
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }
      return response.json()
    })
    .catch((error) => {
      console.error('Fetch error:', error)
      throw error // Re-throw to allow caller to handle
    })
}

// Promise chain error handling
function processDataPipeline(url) {
  return fetchData(url)
    .then((data) => validateData(data))
    .then((validData) => transformData(validData))
    .then((transformedData) => saveData(transformedData))
    .catch((error) => {
      // Handle errors from any step in the chain
      if (error.name === 'ValidationError') {
        return { error: 'Data validation failed' }
      } else if (error.name === 'TransformError') {
        return { error: 'Data transformation failed' }
      } else if (error.name === 'SaveError') {
        return { error: 'Failed to save data' }
      }

      return { error: 'Unknown error in data pipeline' }
    })
}

// Promise.allSettled for handling multiple operations
async function fetchMultipleResources(urls) {
  const promises = urls.map((url) =>
    fetch(url).then((response) => {
      if (!response.ok) throw new Error(`Failed to fetch ${url}`)
      return response.json()
    }),
  )

  const results = await Promise.allSettled(promises)

  const successful = results
    .filter((result) => result.status === 'fulfilled')
    .map((result) => result.value)

  const failed = results
    .filter((result) => result.status === 'rejected')
    .map((result) => result.reason.message)

  return { successful, failed }
}

Custom Error Classes

Creating Meaningful Error Types

javascript
// Base application error
class AppError extends Error {
  constructor(message, statusCode = 500, isOperational = true) {
    super(message)
    this.name = this.constructor.name
    this.statusCode = statusCode
    this.isOperational = isOperational
    this.timestamp = new Date().toISOString()

    Error.captureStackTrace(this, this.constructor)
  }
}

// Specific error types
class ValidationError extends AppError {
  constructor(message, field) {
    super(message, 400)
    this.field = field
    this.type = 'VALIDATION_ERROR'
  }
}

class AuthenticationError extends AppError {
  constructor(message = 'Authentication failed') {
    super(message, 401)
    this.type = 'AUTHENTICATION_ERROR'
  }
}

class AuthorizationError extends AppError {
  constructor(message = 'Access denied') {
    super(message, 403)
    this.type = 'AUTHORIZATION_ERROR'
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404)
    this.type = 'NOT_FOUND_ERROR'
  }
}

class NetworkError extends AppError {
  constructor(message = 'Network request failed') {
    super(message, 0)
    this.type = 'NETWORK_ERROR'
  }
}

// Error factory
class ErrorFactory {
  static createValidationError(field, value, rule) {
    return new ValidationError(
      `Validation failed for field '${field}': ${rule}`,
      field,
    )
  }

  static createNetworkError(url, status) {
    return new NetworkError(
      `Network request to ${url} failed with status ${status}`,
    )
  }

  static fromHttpResponse(response) {
    switch (response.status) {
      case 400:
        return new ValidationError('Bad request')
      case 401:
        return new AuthenticationError()
      case 403:
        return new AuthorizationError()
      case 404:
        return new NotFoundError()
      default:
        return new AppError(`HTTP error: ${response.status}`)
    }
  }
}

Error Logging and Monitoring

Comprehensive Error Logging

javascript
// Error logger utility
class ErrorLogger {
  constructor(config = {}) {
    this.config = {
      logLevel: 'error',
      includeStack: true,
      includeUserAgent: true,
      includeUrl: true,
      maxLogSize: 1000,
      ...config,
    }
    this.logs = []
  }

  log(error, context = {}) {
    const logEntry = {
      timestamp: new Date().toISOString(),
      message: error.message,
      name: error.name,
      stack: this.config.includeStack ? error.stack : undefined,
      context,
      userAgent: this.config.includeUserAgent ? navigator.userAgent : undefined,
      url: this.config.includeUrl ? window.location.href : undefined,
      userId: context.userId,
      sessionId: context.sessionId,
    }

    this.logs.push(logEntry)

    // Keep logs within size limit
    if (this.logs.length > this.config.maxLogSize) {
      this.logs = this.logs.slice(-this.config.maxLogSize)
    }

    // Send to external service
    this.sendToService(logEntry)

    // Console logging for development
    if (process.env.NODE_ENV === 'development') {
      console.error('Error logged:', logEntry)
    }
  }

  async sendToService(logEntry) {
    try {
      await fetch('/api/errors', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(logEntry),
      })
    } catch (sendError) {
      console.error('Failed to send error log:', sendError)
    }
  }

  getLogs() {
    return this.logs
  }

  clearLogs() {
    this.logs = []
  }
}

// Global error handler
class GlobalErrorHandler {
  constructor(logger) {
    this.logger = logger
    this.setupGlobalHandlers()
  }

  setupGlobalHandlers() {
    // Unhandled promise rejections
    window.addEventListener('unhandledrejection', (event) => {
      this.logger.log(event.reason, {
        type: 'unhandled_promise_rejection',
        promise: event.promise,
      })

      // Prevent default browser behavior
      event.preventDefault()
    })

    // Global error handler
    window.addEventListener('error', (event) => {
      this.logger.log(event.error || new Error(event.message), {
        type: 'global_error',
        filename: event.filename,
        lineno: event.lineno,
        colno: event.colno,
      })
    })

    // Resource loading errors
    window.addEventListener(
      'error',
      (event) => {
        if (event.target !== window) {
          this.logger.log(
            new Error(
              `Resource failed to load: ${
                event.target.src || event.target.href
              }`,
            ),
            {
              type: 'resource_error',
              element: event.target.tagName,
              source: event.target.src || event.target.href,
            },
          )
        }
      },
      true,
    )
  }
}

// Usage
const errorLogger = new ErrorLogger({
  logLevel: 'error',
  includeStack: true,
})

const globalErrorHandler = new GlobalErrorHandler(errorLogger)

User-Friendly Error Handling

Error UI Components

javascript
// Error display component
class ErrorDisplay {
  constructor(container) {
    this.container = container
    this.currentError = null
  }

  show(error, options = {}) {
    this.currentError = error

    const errorElement = this.createErrorElement(error, options)
    this.container.innerHTML = ''
    this.container.appendChild(errorElement)

    // Auto-hide after delay if specified
    if (options.autoHide) {
      setTimeout(() => this.hide(), options.autoHide)
    }
  }

  createErrorElement(error, options) {
    const errorDiv = document.createElement('div')
    errorDiv.className = `error-display ${options.type || 'error'}`

    const message = this.getUserFriendlyMessage(error)

    errorDiv.innerHTML = `
      <div class="error-content">
        <div class="error-icon">${this.getErrorIcon(error)}</div>
        <div class="error-message">${message}</div>
        ${
          options.showRetry
            ? '<button class="retry-button">Try Again</button>'
            : ''
        }
        <button class="close-button">ร—</button>
      </div>
    `

    // Add event listeners
    const closeButton = errorDiv.querySelector('.close-button')
    closeButton.addEventListener('click', () => this.hide())

    if (options.showRetry) {
      const retryButton = errorDiv.querySelector('.retry-button')
      retryButton.addEventListener('click', () => {
        this.hide()
        if (options.onRetry) options.onRetry()
      })
    }

    return errorDiv
  }

  getUserFriendlyMessage(error) {
    // Map technical errors to user-friendly messages
    const messageMap = {
      NetworkError: 'Please check your internet connection and try again.',
      ValidationError: 'Please check your input and try again.',
      AuthenticationError: 'Please log in to continue.',
      AuthorizationError: "You don't have permission to perform this action.",
      NotFoundError: 'The requested item could not be found.',
      TimeoutError: 'The request took too long. Please try again.',
    }

    return messageMap[error.name] || 'Something went wrong. Please try again.'
  }

  getErrorIcon(error) {
    const iconMap = {
      NetworkError: '๐ŸŒ',
      ValidationError: 'โš ๏ธ',
      AuthenticationError: '๐Ÿ”’',
      AuthorizationError: '๐Ÿšซ',
      NotFoundError: '๐Ÿ”',
      TimeoutError: 'โฑ๏ธ',
    }

    return iconMap[error.name] || 'โŒ'
  }

  hide() {
    this.container.innerHTML = ''
    this.currentError = null
  }
}

// Form validation with error handling
class FormValidator {
  constructor(form, errorDisplay) {
    this.form = form
    this.errorDisplay = errorDisplay
    this.rules = new Map()
  }

  addRule(fieldName, validator, message) {
    if (!this.rules.has(fieldName)) {
      this.rules.set(fieldName, [])
    }
    this.rules.get(fieldName).push({ validator, message })
  }

  async validate() {
    const errors = []

    for (const [fieldName, rules] of this.rules) {
      const field = this.form.querySelector(`[name="${fieldName}"]`)
      const value = field ? field.value : ''

      for (const rule of rules) {
        try {
          const isValid = await rule.validator(value, this.form)
          if (!isValid) {
            errors.push({
              field: fieldName,
              message: rule.message,
            })
            break // Stop at first error for this field
          }
        } catch (error) {
          errors.push({
            field: fieldName,
            message: 'Validation error occurred',
          })
        }
      }
    }

    if (errors.length > 0) {
      this.displayErrors(errors)
      return false
    }

    this.clearErrors()
    return true
  }

  displayErrors(errors) {
    // Clear previous errors
    this.clearErrors()

    // Display field-specific errors
    errors.forEach((error) => {
      const field = this.form.querySelector(`[name="${error.field}"]`)
      if (field) {
        field.classList.add('error')

        const errorElement = document.createElement('div')
        errorElement.className = 'field-error'
        errorElement.textContent = error.message

        field.parentNode.appendChild(errorElement)
      }
    })

    // Show general error message
    const generalError = new ValidationError(
      `Please fix ${errors.length} error${
        errors.length > 1 ? 's' : ''
      } and try again.`,
    )

    this.errorDisplay.show(generalError, {
      type: 'warning',
      autoHide: 5000,
    })
  }

  clearErrors() {
    // Remove error classes and messages
    this.form.querySelectorAll('.error').forEach((field) => {
      field.classList.remove('error')
    })

    this.form.querySelectorAll('.field-error').forEach((error) => {
      error.remove()
    })
  }
}

Cancellation and Timeout Patterns

Using AbortController for Cancellable Operations

javascript
// Cancellable fetch operations using AbortController
class ApiClient {
  constructor() {
    this.activeRequests = new Map()
  }

  async fetchWithAbort(url, options = {}) {
    const controller = new AbortController()
    const timeoutId = setTimeout(
      () => controller.abort(),
      options.timeout || 5000,
    )

    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal,
      })

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }

      return await response.json()
    } catch (error) {
      if (error.name === 'AbortError') {
        throw new Error('Request was cancelled or timed out')
      }
      throw error
    } finally {
      clearTimeout(timeoutId)
    }
  }

  // Cancel specific request
  async fetchWithCancellation(url, requestId) {
    const controller = new AbortController()
    this.activeRequests.set(requestId, controller)

    try {
      const response = await fetch(url, { signal: controller.signal })
      return await response.json()
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log(`Request ${requestId} was cancelled`)
        return null
      }
      throw error
    } finally {
      this.activeRequests.delete(requestId)
    }
  }

  cancelRequest(requestId) {
    const controller = this.activeRequests.get(requestId)
    if (controller) {
      controller.abort()
    }
  }

  cancelAllRequests() {
    this.activeRequests.forEach((controller) => controller.abort())
    this.activeRequests.clear()
  }
}

// Usage example
const apiClient = new ApiClient()

// Request with timeout
try {
  const data = await apiClient.fetchWithAbort('/api/slow-endpoint', {
    timeout: 3000,
  })
  console.log('Data received:', data)
} catch (error) {
  console.error('Request failed:', error.message)
}

// Cancellable request
const requestId = 'user-search'
apiClient.fetchWithCancellation('/api/search', requestId)

// Cancel if user types more
setTimeout(() => {
  apiClient.cancelRequest(requestId)
}, 1000)

Implementing Retry Logic with Cancellation

javascript
async function fetchWithRetryAndAbort(url, options = {}) {
  const { maxRetries = 3, retryDelay = 1000, timeout = 5000 } = options
  const controller = new AbortController()

  // Set up overall timeout
  const timeoutId = setTimeout(() => controller.abort(), timeout)

  try {
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        const response = await fetch(url, {
          signal: controller.signal,
          ...options,
        })

        if (response.ok) {
          return await response.json()
        }

        // Don't retry for client errors (4xx)
        if (response.status >= 400 && response.status < 500) {
          throw new Error(`Client error: ${response.status}`)
        }

        // Retry for server errors (5xx)
        if (attempt === maxRetries) {
          throw new Error(`Server error after ${maxRetries} attempts`)
        }
      } catch (error) {
        if (error.name === 'AbortError') {
          throw new Error('Request timeout or cancelled')
        }

        if (attempt === maxRetries) {
          throw error
        }

        // Wait before retry, but check if aborted
        await new Promise((resolve, reject) => {
          const delayTimeout = setTimeout(resolve, retryDelay * attempt)

          controller.signal.addEventListener('abort', () => {
            clearTimeout(delayTimeout)
            reject(new Error('Request cancelled during retry delay'))
          })
        })
      }
    }
  } finally {
    clearTimeout(timeoutId)
  }
}

// Advanced cancellation with cleanup
class CancellableOperation {
  constructor() {
    this.controller = new AbortController()
    this.cleanupTasks = []
  }

  get signal() {
    return this.controller.signal
  }

  addCleanupTask(task) {
    this.cleanupTasks.push(task)
  }

  async execute(operation) {
    try {
      return await operation(this.signal)
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('Operation was cancelled')
        return null
      }
      throw error
    } finally {
      await this.cleanup()
    }
  }

  cancel() {
    this.controller.abort()
  }

  async cleanup() {
    for (const task of this.cleanupTasks) {
      try {
        await task()
      } catch (error) {
        console.error('Cleanup task failed:', error)
      }
    }
    this.cleanupTasks.length = 0
  }
}

// Usage
const operation = new CancellableOperation()

operation.addCleanupTask(() => {
  console.log('Cleaning up resources...')
})

const result = await operation.execute(async (signal) => {
  const response = await fetch('/api/data', { signal })
  return response.json()
})

Testing Error Scenarios

Error Testing Utilities

javascript
// Error simulation for testing
class ErrorSimulator {
  constructor() {
    this.originalFetch = window.fetch
    this.originalConsoleError = console.error
    this.simulatedErrors = new Map()
  }

  simulateNetworkError(url, errorType = 'network') {
    this.simulatedErrors.set(url, errorType)
  }

  simulateHttpError(url, status) {
    this.simulatedErrors.set(url, { type: 'http', status })
  }

  enable() {
    window.fetch = async (url, options) => {
      const simulation = this.simulatedErrors.get(url)

      if (simulation) {
        if (simulation === 'network') {
          throw new NetworkError(`Simulated network error for ${url}`)
        } else if (simulation.type === 'http') {
          return {
            ok: false,
            status: simulation.status,
            statusText: 'Simulated Error',
            json: () => Promise.resolve({ error: 'Simulated error' }),
          }
        }
      }

      return this.originalFetch(url, options)
    }
  }

  disable() {
    window.fetch = this.originalFetch
    this.simulatedErrors.clear()
  }

  suppressConsoleErrors() {
    console.error = () => {} // Suppress error logs during testing
  }

  restoreConsoleErrors() {
    console.error = this.originalConsoleError
  }
}

// Test helper functions
function expectError(asyncFunction, expectedErrorType) {
  return asyncFunction()
    .then(() => {
      throw new Error('Expected function to throw an error')
    })
    .catch((error) => {
      if (error.constructor.name !== expectedErrorType) {
        throw new Error(
          `Expected ${expectedErrorType}, but got ${error.constructor.name}`,
        )
      }
      return error
    })
}

// Example test
async function testErrorHandling() {
  const simulator = new ErrorSimulator()
  simulator.enable()
  simulator.suppressConsoleErrors()

  try {
    // Test network error
    simulator.simulateNetworkError('/api/users')
    await expectError(() => fetchUserProfile(1), 'NetworkError')

    // Test HTTP error
    simulator.simulateHttpError('/api/users/1', 404)
    await expectError(() => fetchUserProfile(1), 'NotFoundError')

    console.log('All error handling tests passed!')
  } finally {
    simulator.disable()
    simulator.restoreConsoleErrors()
  }
}

Conclusion

Effective error handling is crucial for building robust JavaScript applications. By implementing comprehensive error handling strategies, you can:

Key takeaways:

  • Use try-catch blocks appropriately for synchronous and asynchronous code
  • Create custom error classes for better error categorization
  • Implement proper error logging and monitoring
  • Provide user-friendly error messages and recovery options
  • Test error scenarios thoroughly
  • Use patterns like circuit breakers and retry logic for resilience
  • Handle both expected and unexpected errors gracefully

Remember that good error handling is not just about catching errorsโ€”it's about providing meaningful feedback, enabling recovery, and maintaining a positive user experience even when things go wrong.

Build resilient applications that your users can trust! ๐Ÿš€