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.
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:
- Callback hell (deeply nested callbacks)
- Easy to forget error handling
- 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:
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:
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:
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:
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:
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:
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:
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:
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:
// 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:
// 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:
- Callbacks: Use error-first pattern (first parameter is an error)
- Promises: Use
.catch()
to handle errors in Promise chains - Async/await: Use
try/catch/finally
blocks for cleaner error handling - Custom error classes: Create specific error types for better error identification
- 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
-
Basic Error Handling: Write a function that fetches data from an API and handles possible network errors.
-
Timeout Implementation: Create a function that adds a timeout to any Promise-based operation.
-
Retry Pattern: Implement a utility that retries failed asynchronous operations a specified number of times before giving up.
-
Error Boundary Component: If you're using React, create an Error Boundary component that catches and displays errors in your components.
-
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! :)