Error Handling Patterns in Async Code

Table of Contents

  • Introduction
  • The Basics of Error Handling
  • Using Try/Catch with Async Functions
  • Handling Multiple Async Operations
  • Returning Errors from Async Functions
  • Handling Errors with Promise.all and Promise.race
  • Graceful Error Handling Strategies
  • Conclusion

Introduction

Asynchronous code is a staple in modern JavaScript and TypeScript programming. Whether you’re fetching data from an API, interacting with a database, or performing any other async operation, error handling is crucial to ensure that your application remains robust and user-friendly. However, handling errors in asynchronous code can be tricky.

In this article, we will explore different error handling patterns for async code in TypeScript, including try/catch blocks, Promise.all, Promise.race, and how to gracefully manage errors without losing control of the application flow.


The Basics of Error Handling

In synchronous JavaScript or TypeScript, errors can be easily caught using try/catch blocks. However, when dealing with asynchronous code, things get more complicated. The primary reason is that asynchronous operations run in the background, and by the time the error occurs, the execution flow may have already moved on.

Common Mistakes in Async Error Handling

A few common mistakes when handling errors in async code include:

  • Not catching promise rejections (unhandled promise rejections).
  • Forgetting to handle errors in parallel async operations.
  • Swallowing errors silently without logging or propagating them.

By understanding and applying error handling patterns, you can avoid these issues and improve the reliability of your async code.


Using Try/Catch with Async Functions

The most basic and widely used method to handle errors in async code is the try/catch block. This method is synchronous and can easily be used within async functions to handle exceptions that occur during the execution of asynchronous operations.

Basic try/catch with Async/Await

async function fetchData(): Promise<string> {
try {
const response = await fetch("https://api.example.com/data");
if (!response.ok) {
throw new Error("Failed to fetch data");
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error occurred while fetching data:", error);
throw error; // Re-throw the error for further handling upstream
}
}

fetchData()
.then(result => console.log(result))
.catch(error => console.log("Caught an error in the caller:", error));

In the above example:

  • The try/catch block is used to catch errors that might occur during the await calls.
  • Errors, such as network issues or non-200 HTTP responses, are caught and logged.
  • After catching the error, we can either handle it or propagate it further by throwing it again.

Best Practices

  • Catch specific errors: Catch the most specific error possible to ensure the error message is meaningful.
  • Log errors: Always log the error details for debugging purposes.
  • Re-throw the error: If needed, re-throw the error so it can be handled by a higher-level error handler.

Handling Multiple Async Operations

When dealing with multiple asynchronous operations, error handling becomes more complex. There are several approaches to handle errors when multiple promises are executed simultaneously.

Using Promise.all

Promise.all runs multiple promises in parallel and waits for all of them to resolve or any of them to reject. If any promise rejects, Promise.all immediately rejects with that error.

async function fetchMultipleData(): Promise<void> {
try {
const [userData, productData] = await Promise.all([
fetchUserData(),
fetchProductData(),
]);
console.log(userData, productData);
} catch (error) {
console.error("Error fetching data:", error);
}
}

fetchMultipleData();

In this case:

  • If either fetchUserData() or fetchProductData() fails, the entire Promise.all fails, and the catch block will execute.
  • It’s crucial to handle errors properly when using Promise.all because the first failure cancels all other promises.

Using Promise.allSettled

Promise.allSettled allows all promises to complete, regardless of whether they resolve or reject. It returns an array of objects describing the result of each promise (either fulfilled or rejected).

async function fetchAllData(): Promise<void> {
const results = await Promise.allSettled([
fetchUserData(),
fetchProductData(),
]);

results.forEach(result => {
if (result.status === "fulfilled") {
console.log("Data fetched:", result.value);
} else {
console.error("Error fetching data:", result.reason);
}
});
}

fetchAllData();

Here:

  • Even if one or more promises fail, Promise.allSettled ensures all promises finish, and each result is handled individually.

When to Use:

  • Use Promise.all when you need all promises to succeed.
  • Use Promise.allSettled when you want to continue processing even if some promises fail.

Returning Errors from Async Functions

It’s essential to consider how errors are returned from async functions. By convention, async functions return a Promise, and that promise resolves with the result or rejects with an error.

Handling Rejections

If you expect that an async function might reject, ensure that the caller of the function handles the rejection properly, either with .catch() or try/catch blocks.

async function getUserById(id: number): Promise<User> {
const response = await fetch(`/api/user/${id}`);
if (!response.ok) {
throw new Error("User not found");
}
return response.json();
}

getUserById(123)
.then(user => console.log(user))
.catch(error => console.error("Error fetching user:", error));
  • Graceful rejection handling: Catch the rejection in the caller to handle it appropriately and provide feedback to the user.

Custom Error Handling

In some cases, you may need to define custom error classes to handle specific errors more gracefully:

class FetchError extends Error {
constructor(message: string) {
super(message);
this.name = "FetchError";
}
}

async function fetchData(): Promise<any> {
try {
const data = await fetch("https://api.example.com/data");
if (!data.ok) {
throw new FetchError("Failed to fetch data");
}
return data.json();
} catch (error) {
if (error instanceof FetchError) {
console.error(error.message);
} else {
console.error("An unknown error occurred", error);
}
throw error;
}
}

This pattern allows you to identify and handle different error types more clearly.


Handling Errors with Promise.race

Promise.race runs multiple promises in parallel but returns the result of the first promise to resolve (or reject). It is useful when you want to manage timeouts or race conditions.

Example with Timeout

function fetchDataWithTimeout(url: string, timeout: number): Promise<any> {
const fetchPromise = fetch(url).then(response => response.json());
const timeoutPromise = new Promise<any>((_, reject) =>
setTimeout(() => reject(new Error("Request timed out")), timeout)
);

return Promise.race([fetchPromise, timeoutPromise]);
}

fetchDataWithTimeout("https://api.example.com/data", 5000)
.then(data => console.log("Data fetched:", data))
.catch(error => console.error("Error:", error));

Here:

  • If the fetch takes longer than the timeout, Promise.race ensures the timeout error is thrown first.

Graceful Error Handling Strategies

Here are some strategies for graceful error handling in async code:

  1. User-Friendly Error Messages: Always provide helpful, user-friendly error messages when errors are thrown. For example, instead of a generic “Something went wrong”, say “Unable to fetch data. Please check your connection.”
  2. Logging Errors: Ensure you log errors for debugging purposes, but avoid exposing stack traces to the user in production environments.
  3. Retries for Network Errors: For network-related errors, consider implementing retry logic to handle intermittent issues.
  4. Fallback Data: If your app relies on external data, consider having fallback data or functionality to continue operating even when the external resource fails.

Conclusion

Error handling in async code is critical to maintaining the stability and usability of your application. By using patterns such as try/catch, Promise.all, Promise.allSettled, and custom error handling, you can ensure that errors are dealt with gracefully and consistently.

Remember to:

  • Handle errors as early as possible.
  • Provide useful error messages.
  • Always ensure that asynchronous errors are caught and managed appropriately.

By implementing these patterns, your asynchronous code will be more reliable, and you will avoid common pitfalls that could otherwise lead to silent failures or difficult-to-trace bugs.