Skip to main content

JavaScript Async Error Handling

Introduction

Asynchronous operations are a fundamental part of JavaScript programming, especially when dealing with network requests, file operations, or user interactions. However, these operations can fail, and handling errors in asynchronous code requires special techniques that differ from synchronous error handling.

In this tutorial, you'll learn how to properly handle errors in various asynchronous JavaScript patterns:

  • Callback-based asynchronous operations
  • Promises
  • Async/await functions
  • Event-driven asynchronous code

Let's dive in and master asynchronous error handling!

Callback Error Handling

The Traditional Callback Pattern

In early JavaScript, asynchronous operations were handled using callbacks. The common pattern was to provide a function that accepts an error as its first parameter.

javascript
function fetchData(callback) {
setTimeout(() => {
// Simulate a network request
const success = Math.random() > 0.3; // 70% chance of success

if (success) {
callback(null, { data: "This is the fetched data" });
} else {
callback(new Error("Failed to fetch data"), null);
}
}, 1000);
}

// Using the function
fetchData((error, data) => {
if (error) {
console.error("Error occurred:", error.message);
return;
}
console.log("Data received:", data);
});

Output:

// Success case:
Data received: { data: "This is the fetched data" }

// Error case:
Error occurred: Failed to fetch data

The Problem with Callbacks

While this pattern works, it has several drawbacks:

  1. Callback hell (deeply nested callbacks)
  2. Easy to forget error handling
  3. No standardized way to propagate errors

Let's see how Promises help solve these issues.

Promise Error Handling

Using .catch()

Promises provide a more elegant way to handle asynchronous operations and their errors:

javascript
function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.3; // 70% chance of success

if (success) {
resolve({ data: "This is the fetched data" });
} else {
reject(new Error("Failed to fetch data"));
}
}, 1000);
});
}

// Using the Promise with .then() and .catch()
fetchData()
.then(data => {
console.log("Data received:", data);
})
.catch(error => {
console.error("Error caught:", error.message);
});

Output:

// Success case:
Data received: { data: "This is the fetched data" }

// Error case:
Error caught: Failed to fetch data

Promise Chaining and Error Propagation

One of the advantages of Promises is how errors propagate down the chain until caught:

javascript
fetchData()
.then(data => {
console.log("First operation:", data);
// This will throw an error
const processedData = data.nonExistentProperty.something;
return processedData;
})
.then(processedData => {
console.log("Second operation:", processedData);
return processedData;
})
.catch(error => {
console.error("Error caught in the chain:", error.message);
});

Output:

// First shows the first operation log, then:
Error caught in the chain: Cannot read properties of undefined (reading 'something')

The finally() Method

The finally() method runs regardless of whether the Promise was fulfilled or rejected:

javascript
let isLoading = true;

fetchData()
.then(data => {
console.log("Data received:", data);
})
.catch(error => {
console.error("Error caught:", error.message);
})
.finally(() => {
isLoading = false;
console.log("Loading finished, isLoading =", isLoading);
});

Output:

// Success case:
Data received: { data: "This is the fetched data" }
Loading finished, isLoading = false

// Error case:
Error caught: Failed to fetch data
Loading finished, isLoading = false

Async/Await Error Handling

Using try...catch with async/await

Async/await provides an even more intuitive way to handle asynchronous operations, making them look and behave more like synchronous code:

javascript
async function getData() {
try {
const data = await fetchData();
console.log("Data received:", data);
return data;
} catch (error) {
console.error("Error caught in async function:", error.message);
// You can either return a default value or re-throw the error
return { data: "Default data due to error" };
} finally {
console.log("Async operation completed");
}
}

// Execute the async function
getData().then(result => {
console.log("Final result:", result);
});

Output:

// Success case:
Data received: { data: "This is the fetched data" }
Async operation completed
Final result: { data: "This is the fetched data" }

// Error case:
Error caught in async function: Failed to fetch data
Async operation completed
Final result: { data: "Default data due to error" }

Handling Multiple Async Operations

When dealing with multiple asynchronous operations, you can wrap await calls in try-catch blocks:

javascript
async function processMultipleRequests() {
try {
const data1 = await fetchData();
console.log("First data received:", data1);

try {
// This might fail independently
const data2 = await fetchAnotherResource();
console.log("Second data received:", data2);
} catch (error) {
console.error("Error in second request:", error.message);
// Continue execution with default data
return { combinedData: data1, secondData: "default" };
}

return { success: true, data: [data1, data2] };
} catch (error) {
console.error("Error in first request:", error.message);
return { success: false, error: error.message };
}
}

Using Promise.all with Error Handling

For parallel async operations, you can use Promise.all with try-catch:

javascript
async function fetchMultipleResources() {
try {
const results = await Promise.all([
fetchData(),
fetchUserProfile(),
fetchSettings()
]);

console.log("All data fetched successfully:", results);
return results;
} catch (error) {
console.error("At least one request failed:", error.message);
// Promise.all fails fast - if any promise rejects, the whole operation fails
return null;
}
}

Advanced Error Handling Patterns

Creating Custom Error Classes

Custom error classes can make your error handling more descriptive:

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

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

async function fetchWithCustomErrors(url) {
try {
const response = await fetch(url);

if (!response.ok) {
throw new NetworkError(`HTTP error ${response.status}`, response.status);
}

const data = await response.json();

if (!data.isValid) {
throw new ValidationError("Invalid data received", data.invalidField);
}

return data;
} catch (error) {
if (error instanceof NetworkError) {
console.error(`Network error (${error.statusCode}):`, error.message);
} else if (error instanceof ValidationError) {
console.error(`Validation error in field ${error.fieldName}:`, error.message);
} else {
console.error("Unknown error:", error.message);
}
throw error; // Re-throw to allow the caller to handle it too
}
}

Handling Timeouts in Async Operations

Sometimes async operations can take too long. Here's how to implement timeouts:

javascript
function fetchWithTimeout(url, timeoutMs = 5000) {
return Promise.race([
fetch(url),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
)
]);
}

// Usage
async function getSafeData() {
try {
const response = await fetchWithTimeout('https://api.example.com/data', 3000);
return await response.json();
} catch (error) {
console.error('Error fetching data:', error.message);
return null;
}
}

Real-World Example: Error Handling in API Calls

Let's put everything together in a real-world example of fetching data from an API:

javascript
// Utility for making API requests with proper error handling
class APIClient {
constructor(baseURL) {
this.baseURL = baseURL;
}

async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeout || 10000);

try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});

clearTimeout(timeoutId);

if (!response.ok) {
// Handle HTTP errors
const errorData = await response.json().catch(() => ({}));
throw new NetworkError(
errorData.message || `HTTP error ${response.status}`,
response.status,
errorData
);
}

return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('Request timed out');
}
throw error;
}
}

async get(endpoint, options = {}) {
return this.request(endpoint, { ...options, method: 'GET' });
}

async post(endpoint, data, options = {}) {
return this.request(endpoint, {
...options,
method: 'POST',
body: JSON.stringify(data)
});
}
}

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

async function getUserProfile(userId) {
try {
const userData = await api.get(`/users/${userId}`);
console.log('User data fetched successfully:', userData);
return userData;
} catch (error) {
if (error instanceof NetworkError && error.statusCode === 404) {
console.warn(`User with ID ${userId} not found`);
return null;
} else {
console.error('Failed to fetch user profile:', error.message);
throw error; // Re-throw for higher-level handling
}
}
}

// Component using the API
async function displayUserDashboard(userId) {
try {
const userProfile = await getUserProfile(userId);
if (!userProfile) {
document.getElementById('dashboard').innerHTML = 'User not found';
return;
}

// Continue with more API calls that might fail
try {
const userPosts = await api.get(`/users/${userId}/posts`);
renderDashboard(userProfile, userPosts);
} catch (error) {
// Handle specific error but still show dashboard with available data
console.warn('Could not load user posts:', error.message);
renderDashboard(userProfile, []);
}
} catch (error) {
// Critical error handling
document.getElementById('dashboard').innerHTML =
`<div class="error">Failed to load dashboard: ${error.message}</div>`;
} finally {
document.getElementById('loading').style.display = 'none';
}
}

Event-Based Error Handling

In event-driven programming, error handling requires different approaches:

javascript
// Event-based error handling pattern
const eventSource = new EventSource('/api/events');

eventSource.addEventListener('message', event => {
try {
const data = JSON.parse(event.data);
processEventData(data);
} catch (error) {
console.error('Error processing event data:', error);
// Don't close the connection, just handle this specific event error
}
});

eventSource.addEventListener('error', event => {
console.error('EventSource failed:', event);

if (eventSource.readyState === EventSource.CLOSED) {
// Connection was closed
console.log('Connection closed, attempting to reconnect...');
setTimeout(() => {
// Create a new connection
// Additional reconnection logic
}, 5000);
}
});

Summary

Asynchronous error handling in JavaScript requires different strategies compared to synchronous code:

  1. Callbacks: Use error-first pattern (first parameter is an error)
  2. Promises: Use .catch() to handle errors in Promise chains
  3. Async/await: Use try/catch/finally blocks for cleaner error handling
  4. Custom error classes: Create specific error types for better error identification
  5. Advanced patterns: Implement timeouts, retries, and graceful fallbacks

Remember these key principles:

  • Always handle errors in asynchronous operations
  • Be specific about what failed and why
  • Provide fallback options when appropriate
  • Clean up resources in finally blocks
  • Use appropriate error handling strategy based on the async pattern

Additional Resources and Exercises

Resources

Exercises

  1. Basic Error Handling: Write a function that fetches data from an API and handles possible network errors.

  2. Timeout Implementation: Create a function that adds a timeout to any Promise-based operation.

  3. Retry Pattern: Implement a utility that retries failed asynchronous operations a specified number of times before giving up.

  4. Error Boundary Component: If you're using React, create an Error Boundary component that catches and displays errors in your components.

  5. Advanced Challenge: Build a data fetching library with built-in error handling for different types of errors (network, validation, timeout, etc.).

By mastering async error handling in JavaScript, you'll build more robust and user-friendly applications that gracefully handle failures instead of crashing or leaving users confused.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)