Skip to main content
โšก Calmops

Error Handling with Promises and Async/Await in JavaScript

Error Handling with Promises and Async/Await in JavaScript

Introduction

Error handling in asynchronous code is crucial for building reliable applications. Unlike synchronous code where you can use try-catch blocks, asynchronous operations require different approaches. Understanding how to properly handle errors in promises and async/await is essential for writing production-ready JavaScript.

In this article, you’ll learn comprehensive error handling techniques for both promises and async/await, including best practices and real-world patterns.

Promise Error Handling

Using .catch() Method

The .catch() method handles promise rejections.

// Basic promise rejection handling
fetch('/api/users')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    console.error('Error:', error.message);
  });

// Catching specific errors
fetch('/api/users')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
  })
  .then(data => console.log(data))
  .catch(error => {
    console.error('Failed to fetch users:', error);
  });

Chaining Multiple .catch() Handlers

// Different catch handlers for different errors
fetch('/api/users')
  .then(response => response.json())
  .catch(error => {
    console.error('Network error:', error);
    throw error; // Re-throw to next catch
  })
  .then(data => processData(data))
  .catch(error => {
    console.error('Processing error:', error);
  });

// Catching at different levels
Promise.resolve()
  .then(() => {
    throw new Error('Error in first then');
  })
  .catch(error => {
    console.error('Caught:', error.message);
    return 'recovered';
  })
  .then(result => {
    console.log('Result:', result); // 'recovered'
  });

Promise.prototype.finally()

The .finally() method runs regardless of whether the promise resolves or rejects.

// Cleanup with finally
let isLoading = true;

fetch('/api/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error(error))
  .finally(() => {
    isLoading = false;
    console.log('Request completed');
  });

// Practical example: Loading state
function fetchWithLoading(url) {
  console.log('Loading...');
  
  return fetch(url)
    .then(response => response.json())
    .catch(error => {
      console.error('Error:', error);
      throw error;
    })
    .finally(() => {
      console.log('Loading complete');
    });
}

Async/Await Error Handling

Using try-catch Blocks

The try-catch block is the primary way to handle errors in async/await.

// Basic try-catch with async/await
async function fetchUsers() {
  try {
    const response = await fetch('/api/users');
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Error fetching users:', error);
  }
}

// Calling the async function
fetchUsers();

// With return value
async function getUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      throw new Error(`User not found: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error('Failed to get user:', error);
    return null;
  }
}

try-catch-finally with Async/Await

// Complete error handling pattern
async function processData(url) {
  let data;
  
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    data = await response.json();
    console.log('Data processed:', data);
  } catch (error) {
    console.error('Error processing data:', error);
    data = null;
  } finally {
    console.log('Processing complete');
  }
  
  return data;
}

// Practical example: Database operations
async function saveUserData(user) {
  let connection;
  
  try {
    connection = await connectToDatabase();
    await connection.save(user);
    console.log('User saved successfully');
  } catch (error) {
    console.error('Failed to save user:', error);
    throw error;
  } finally {
    if (connection) {
      await connection.close();
    }
  }
}

Error Types and Handling

Distinguishing Error Types

// Handling different error types
async function robustFetch(url) {
  try {
    const response = await fetch(url);
    const data = await response.json();
    return data;
  } catch (error) {
    if (error instanceof TypeError) {
      console.error('Network error:', error.message);
    } else if (error instanceof SyntaxError) {
      console.error('Invalid JSON:', error.message);
    } else {
      console.error('Unknown error:', error);
    }
  }
}

// Custom error handling
async function fetchWithErrorHandling(url) {
  try {
    const response = await fetch(url);
    
    if (response.status === 404) {
      throw new Error('Resource not found');
    }
    if (response.status === 401) {
      throw new Error('Unauthorized');
    }
    if (response.status === 500) {
      throw new Error('Server error');
    }
    
    return await response.json();
  } catch (error) {
    if (error.message === 'Unauthorized') {
      // Handle unauthorized
      redirectToLogin();
    } else if (error.message === 'Server error') {
      // Handle server error
      showErrorNotification('Server is down');
    } else {
      // Handle other errors
      console.error(error);
    }
  }
}

Creating Custom Error Classes

// Custom error class
class APIError extends Error {
  constructor(message, status, response) {
    super(message);
    this.name = 'APIError';
    this.status = status;
    this.response = response;
  }
}

// Using custom error
async function fetchAPI(url) {
  try {
    const response = await fetch(url);
    
    if (!response.ok) {
      throw new APIError(
        `API request failed: ${response.statusText}`,
        response.status,
        response
      );
    }
    
    return await response.json();
  } catch (error) {
    if (error instanceof APIError) {
      console.error(`API Error ${error.status}: ${error.message}`);
    } else {
      console.error('Unexpected error:', error);
    }
    throw error;
  }
}

Advanced Error Handling Patterns

Pattern 1: Retry Logic

// Retry with exponential backoff
async function fetchWithRetry(url, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      return await response.json();
    } catch (error) {
      if (i === maxRetries - 1) {
        throw error; // Last attempt failed
      }
      
      // Wait before retrying
      const delay = Math.pow(2, i) * 1000; // Exponential backoff
      console.log(`Retry attempt ${i + 1} after ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
try {
  const data = await fetchWithRetry('/api/data');
  console.log(data);
} catch (error) {
  console.error('Failed after retries:', error);
}

Pattern 2: Error Recovery

// Graceful error recovery
async function fetchUserWithFallback(userId) {
  try {
    // Try primary API
    return await fetch(`/api/users/${userId}`).then(r => r.json());
  } catch (error) {
    console.warn('Primary API failed, trying fallback');
    
    try {
      // Try fallback API
      return await fetch(`/api/backup/users/${userId}`).then(r => r.json());
    } catch (fallbackError) {
      console.error('Both APIs failed');
      
      // Return cached data or default
      return {
        id: userId,
        name: 'Unknown User',
        cached: true
      };
    }
  }
}

Pattern 3: Error Aggregation

// Collect multiple errors
async function processMultipleRequests(urls) {
  const results = [];
  const errors = [];
  
  for (const url of urls) {
    try {
      const response = await fetch(url);
      const data = await response.json();
      results.push(data);
    } catch (error) {
      errors.push({
        url,
        error: error.message
      });
    }
  }
  
  return { results, errors };
}

// Usage
const { results, errors } = await processMultipleRequests([
  '/api/users',
  '/api/posts',
  '/api/comments'
]);

if (errors.length > 0) {
  console.warn('Some requests failed:', errors);
}

Pattern 4: Timeout Handling

// Fetch with timeout
async function fetchWithTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, {
      signal: controller.signal
    });
    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new Error(`Request timeout after ${timeout}ms`);
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

// Usage
try {
  const data = await fetchWithTimeout('/api/data', 3000);
  console.log(data);
} catch (error) {
  console.error('Request failed:', error.message);
}

Practical Real-World Examples

Example 1: API Client with Error Handling

class APIClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
  }
  
  async request(endpoint, options = {}) {
    const url = `${this.baseURL}${endpoint}`;
    
    try {
      const response = await fetch(url, {
        headers: {
          'Content-Type': 'application/json',
          ...options.headers
        },
        ...options
      });
      
      if (!response.ok) {
        const error = await response.json().catch(() => ({}));
        throw new Error(error.message || `HTTP ${response.status}`);
      }
      
      return await response.json();
    } catch (error) {
      console.error(`API request failed: ${error.message}`);
      throw error;
    }
  }
  
  async get(endpoint) {
    return this.request(endpoint, { method: 'GET' });
  }
  
  async post(endpoint, data) {
    return this.request(endpoint, {
      method: 'POST',
      body: JSON.stringify(data)
    });
  }
}

// Usage
const api = new APIClient('https://api.example.com');

try {
  const users = await api.get('/users');
  console.log(users);
} catch (error) {
  console.error('Failed to fetch users:', error);
}

Example 2: Form Submission with Error Handling

async function handleFormSubmit(event) {
  event.preventDefault();
  
  const form = event.target;
  const submitButton = form.querySelector('button[type="submit"]');
  const errorContainer = form.querySelector('.error-message');
  
  try {
    // Disable button and show loading state
    submitButton.disabled = true;
    submitButton.textContent = 'Submitting...';
    errorContainer.textContent = '';
    
    // Collect form data
    const formData = new FormData(form);
    const data = Object.fromEntries(formData);
    
    // Validate data
    if (!data.email || !data.password) {
      throw new Error('Email and password are required');
    }
    
    // Submit form
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
    
    if (!response.ok) {
      throw new Error('Login failed');
    }
    
    const result = await response.json();
    console.log('Login successful:', result);
    
    // Redirect or update UI
    window.location.href = '/dashboard';
    
  } catch (error) {
    // Display error message
    errorContainer.textContent = error.message;
    errorContainer.style.display = 'block';
    console.error('Form submission error:', error);
    
  } finally {
    // Re-enable button
    submitButton.disabled = false;
    submitButton.textContent = 'Submit';
  }
}

Example 3: Data Processing Pipeline

async function processDataPipeline(dataSource) {
  try {
    // Step 1: Fetch data
    console.log('Fetching data...');
    const rawData = await fetchData(dataSource);
    
    // Step 2: Validate data
    console.log('Validating data...');
    const validatedData = validateData(rawData);
    
    // Step 3: Transform data
    console.log('Transforming data...');
    const transformedData = await transformData(validatedData);
    
    // Step 4: Save data
    console.log('Saving data...');
    const result = await saveData(transformedData);
    
    console.log('Pipeline completed successfully');
    return result;
    
  } catch (error) {
    console.error('Pipeline failed:', error);
    
    // Log error details
    console.error('Error type:', error.constructor.name);
    console.error('Error message:', error.message);
    console.error('Stack trace:', error.stack);
    
    // Notify user
    showErrorNotification('Data processing failed. Please try again.');
    
    throw error;
  }
}

async function fetchData(source) {
  const response = await fetch(source);
  if (!response.ok) throw new Error('Failed to fetch data');
  return response.json();
}

function validateData(data) {
  if (!Array.isArray(data)) {
    throw new Error('Data must be an array');
  }
  return data;
}

async function transformData(data) {
  return data.map(item => ({
    ...item,
    processed: true,
    timestamp: new Date()
  }));
}

async function saveData(data) {
  const response = await fetch('/api/save', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
  
  if (!response.ok) throw new Error('Failed to save data');
  return response.json();
}

Common Mistakes to Avoid

Mistake 1: Not Handling Promise Rejections

// โŒ Wrong - Unhandled rejection
async function fetchData() {
  const data = await fetch('/api/data').then(r => r.json());
  return data;
}

// โœ… Correct - Handle rejection
async function fetchData() {
  try {
    const data = await fetch('/api/data').then(r => r.json());
    return data;
  } catch (error) {
    console.error('Error:', error);
    throw error;
  }
}

Mistake 2: Swallowing Errors Silently

// โŒ Wrong - Error is hidden
async function getData() {
  try {
    return await fetch('/api/data').then(r => r.json());
  } catch (error) {
    // Error is silently ignored
  }
}

// โœ… Correct - Log or handle error
async function getData() {
  try {
    return await fetch('/api/data').then(r => r.json());
  } catch (error) {
    console.error('Failed to get data:', error);
    throw error; // Re-throw if needed
  }
}

Mistake 3: Not Checking Response Status

// โŒ Wrong - Doesn't check if response is OK
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json(); // May parse error response
}

// โœ… Correct - Check response status
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json();
}

Summary

Proper error handling in asynchronous code is essential:

  • Use .catch() for promise-based error handling
  • Use try-catch blocks with async/await for cleaner code
  • Always use .finally() for cleanup operations
  • Distinguish between different error types
  • Implement retry logic for transient failures
  • Create custom error classes for better error handling
  • Always check response status in fetch requests
  • Log errors appropriately for debugging
  • Provide meaningful error messages to users

Next Steps

Continue your learning journey:

Comments