DEV Community

Uzair Saleem
Uzair Saleem

Posted on

Best Practices for Error Handling with async/await in JavaScript

Error handling in async/await in JavaScript is crucial for building robust and reliable applications. While async/await makes asynchronous code look synchronous, you still need to actively manage potential errors. Let's dive into the best practices.

1. try...catch Blocks for Asynchronous Operations

This is the fundamental way to handle errors in async/await. Wrap the await calls that might throw an error within a try block, and handle the error in the catch block.


    async function fetchData() {
      try {
        const response = await fetch('https://api.example.com/data');

        // Always check response.ok for network requests!
        if (!response.ok) {
          // Handle non-2xx status codes by throwing an error
          throw new Error(`HTTP error! Status: ${response.status}`);
        }

        const data = await response.json();
        console.log('Data:', data);
      } catch (error) {
        // Catch any errors thrown in the try block (e.g., network issues, HTTP errors)
        console.error('Error fetching data:', error.message);
        // You might want to rethrow the error, display a message to the user, etc.
      }
    }

    fetchData();
Enter fullscreen mode Exit fullscreen mode

2. Handle Errors at the Appropriate Level

Decide where an error should be caught and handled. Not every error needs to be handled at the lowest level. Sometimes, it's better to let an error propagate up the call stack until a more suitable level can deal with it (e.g., displaying an error message to the user, logging the error, or triggering a retry mechanism).

// Lower level function - lets errors propagate
    async function fetchUserPosts(userId) {
      const user = await getUser(userId); // This might throw an error (e.g., user not found)
      const posts = await getPostsForUser(user.id); // This might throw an error (e.g., no posts)
      return posts;
    }

    // Higher level function - handles the error from above
    async function displayUserContent(userId) {
      try {
        const posts = await fetchUserPosts(userId);
        renderPosts(posts);
      } catch (error) {
        console.error('Failed to display user content:', error.message);
        // Provide a user-friendly message
        showErrorMessageToUser('Could not load user content. Please try again.');
        // Optionally, log the full error details
        logErrorToService(error, { context: 'displayUserContent' });
      }
    }
Enter fullscreen mode Exit fullscreen mode

3. Graceful Degradation and Fallbacks

When an error occurs, consider what a "good" fallback experience might be for the user. Instead of completely crashing or showing a generic error, can you:

  • Display cached data?
  • Show a skeleton loader with an error state?
  • Provide a retry button?
  • Disable a specific feature that relies on the failed operation?
async function loadProductDetails(productId) {
      try {
        const product = await fetchProduct(productId);
        renderProduct(product);
      } catch (error) {
        console.error('Error loading product:', error);
        // Graceful fallback: display a default message and a retry button
        renderErrorMessage(
          'Failed to load product details. Please try again.',
          () => loadProductDetails(productId)
        );
      }
    }
Enter fullscreen mode Exit fullscreen mode

4. Custom Error Classes

For more complex applications, create custom error classes that extend Error. This allows you to differentiate between different types of errors and handle them more specifically.

class NetworkError extends Error {
      constructor(message) {
        super(message);
        this.name = 'NetworkError';
      }
    }

    class ValidationError extends Error {
      constructor(message, details) {
        super(message);
        this.name = 'ValidationError';
        this.details = details; // Specific validation details
      }
    }

    async function submitForm(data) {
      try {
        const response = await fetch('/api/submit', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(data),
        });

        if (!response.ok) {
          if (response.status === 400) {
            const errors = await response.json();
            throw new ValidationError('Invalid form data', errors.errors);
          } else if (response.status >= 500) {
            throw new NetworkError(`Server error (${response.status}) submitting form`);
          } else {
            // Catch any other non-2xx status codes
            throw new Error(`Unexpected HTTP error: ${response.status}`);
          }
        }
        return await response.json();
      } catch (error) {
        if (error instanceof ValidationError) {
          console.warn('Validation failed:', error.details);
          // Display specific validation messages to the user (e.g., next to input fields)
          displayFormValidationErrors(error.details);
        } else if (error instanceof NetworkError) {
          console.error('Network error:', error.message);
          // Inform the user about network issues
          showToast('Connection issue. Please check your internet.');
        } else {
          console.error('An unexpected error occurred:', error);
          showGenericErrorMessage();
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

5. Centralized Error Logging

For production applications, don't just console.error. Send errors to a centralized logging service (e.g., Sentry, Bugsnag, Datadog, ELK stack, cloud logging services). This helps you monitor your application's health and identify issues proactively.

// A simple logger abstraction
    const AppLogger = {
      error(error, context = {}) {
        console.error('Application Error:', error, context);
        // In a real app, send this to a service like Sentry or Bugsnag
        // Sentry.captureException(error, { extra: context });
      },
      info(message, context = {}) {
        console.log(message, context);
      },
    };

    async function performImportantTask() {
      try {
        await someRiskyOperation();
        AppLogger.info('Important task completed successfully.');
      } catch (error) {
        AppLogger.error(error, { task: 'ImportantTask', userId: 'xyz' });
        // User-facing message
        showErrorMessageToUser('Something went wrong. We have been notified of the issue.');
      }
    }

Enter fullscreen mode Exit fullscreen mode

6. Using finally for Cleanup

The finally block ensures that certain code runs regardless of whether an error occurred or not. This is incredibly useful for cleanup tasks like closing connections, releasing resources, or hiding loading spinners.

async function uploadFile(file) {
      showLoadingSpinner(); // Show spinner before starting

      try {
        const response = await fetch('/upload', {
          method: 'POST',
          body: file,
        });
        if (!response.ok) {
          throw new Error('Upload failed');
        }
        console.log('File uploaded successfully');
        showSuccessMessage('File uploaded!');
      } catch (error) {
        console.error('Upload error:', error.message);
        showErrorMessageToUser('File upload failed. Please try again.');
      } finally {
        hideLoadingSpinner(); // Always hide spinner, success or failure
      }
    }

Enter fullscreen mode Exit fullscreen mode

7. Avoid Swallowing Errors

Don't just catch an error and do nothing. This is known as "swallowing" an error and makes debugging incredibly difficult because the error vanishes without a trace. If you catch an error, either handle it meaningfully (e.g., display to user, log it) or rethrow it (or a new, more specific error) to propagate it up the call stack.

Bad Practice (Don't do this!):

async function doSomethingBad() {
      try {
        await someFailingOperation();
      } catch (error) {
        // Doing nothing with the error - BAD!
        // The error is gone, and you'll never know it happened.
      }
    }
Enter fullscreen mode Exit fullscreen mode

Good Practice (Handle or rethrow):

async function processData() {
      try {
        const data = await fetchDataFromAPI();
        return processAndValidate(data);
      } catch (error) {
        console.error('Error in processData:', error);
        throw new Error('Failed to process data due to API error.'); // Rethrow a more generic error
      }
    }
Enter fullscreen mode Exit fullscreen mode

8. Promise-based Error Handling (for unawaited promises)

If you have promises that are not awaited, you must use .catch() for their error handling, as try...catch will not intercept errors from unawaited promises in the same scope. This is a common pitfall!

async function demoUnawaitedPromiseError() {
      try {
        // This promise is not awaited. Its rejection will NOT be caught here.
        new Promise((resolve, reject) => {
          setTimeout(() => reject(new Error('Uncaught promise error!')), 100);
        });
        console.log('This will still print, but the promise error is unhandled by this try...catch.');
      } catch (error) {
        // This catch block will NOT execute for the unawaited promise above.
        console.error('This try...catch will NOT catch the promise error.');
      }
    }

    demoUnawaitedPromiseError();

    // Correct way to handle errors in unawaited promises:
    function anotherDemo() {
      new Promise((resolve, reject) => {
        setTimeout(() => reject(new Error('Handled promise error via .catch()!')), 100);
      })
        .then((data) => console.log('Promise resolved:', data))
        .catch((error) => {
          console.error('Caught with .catch():', error.message);
        });
    }
    anotherDemo();
Enter fullscreen mode Exit fullscreen mode

9. Global Unhandled Promise Rejection Handling

For unforeseen errors from unhandled promise rejections (which are often from promises not awaited or explicitly .catch()ed), you can set up global handlers. These are a last resort for catching bugs you might have missed.

Node.js:

process.on('unhandledRejection', (reason, promise) => {
      console.error('Unhandled Rejection at:', promise, 'reason:', reason);
      // Log this to your logging service (Sentry, etc.)
      // Sentry.captureException(reason);
      // Depending on the severity, you might want to exit the process:
      // process.exit(1);
    });

Enter fullscreen mode Exit fullscreen mode

Browsers:

window.addEventListener('unhandledrejection', (event) => {
      console.error('Unhandled Rejection (Browser):', event.reason);
      // event.reason holds the error object
      // Log event.reason to your logging service
      // Sentry.captureException(event.reason);
      // Prevent default handling if you're taking over
      // event.preventDefault();
    });

Enter fullscreen mode Exit fullscreen mode

While useful for catching last-resort issues, relying heavily on global handlers is generally a sign that specific try...catch or .catch() blocks are missing in your application logic. Always strive for explicit error handling.

By consistently applying these best practices, you can build more resilient, maintainable, and user-friendly applications with async/await. Happy coding!

Top comments (0)