Error Handling in Asynchronous Code
Why Asynchronous Error Handling Matters
Asynchronous operations are inherently unreliable. Network requests can fail, servers can go down, timeouts can expire, and user connections can drop. Unlike synchronous code where errors follow a predictable path, asynchronous errors can occur at any point in time and must be caught explicitly. If you do not handle these errors, your application will crash, display broken interfaces, or silently lose data. Robust error handling is the difference between a professional application and a fragile prototype.
In this lesson, you will learn how to identify, catch, and recover from every type of error that can occur in asynchronous JavaScript code. We will build progressively more sophisticated patterns, from basic try/catch blocks to production-ready retry logic and circuit breakers.
Types of Errors in Asynchronous Code
Before you can handle errors effectively, you need to understand the different categories of errors you will encounter when working with APIs and asynchronous operations.
- Network Errors -- The request never reached the server. This happens when the user is offline, the DNS lookup fails, or the server is unreachable. The
fetch()Promise rejects with aTypeError. - Timeout Errors -- The server took too long to respond. The request was sent but the response never arrived within the expected time frame.
- HTTP Errors -- The server received the request but returned an error status code (4xx or 5xx). Note that
fetch()does NOT reject for HTTP errors -- you must checkresponse.okmanually. - Validation Errors -- The server rejected the request because the data you sent was invalid. These typically come back as 400 or 422 status codes with details about which fields failed.
- Server Errors -- Something went wrong on the server side (500, 502, 503). These are not your fault, but you still need to handle them gracefully.
- Parsing Errors -- The response body could not be parsed as JSON. This happens when the server returns HTML error pages instead of JSON, or when the response is corrupted.
- Authentication Errors -- Your token expired or is invalid (401), or you lack permission for the requested resource (403).
Example: Identifying Error Types
async function identifyError(url) {
try {
const response = await fetch(url);
// HTTP errors -- fetch does NOT reject for these
if (response.status === 401) {
throw new Error('AUTHENTICATION: Please log in again');
}
if (response.status === 403) {
throw new Error('PERMISSION: You lack access to this resource');
}
if (response.status === 404) {
throw new Error('NOT_FOUND: The requested resource does not exist');
}
if (response.status === 422) {
const details = await response.json();
throw new Error('VALIDATION: ' + JSON.stringify(details.errors));
}
if (response.status === 429) {
throw new Error('RATE_LIMIT: Too many requests, slow down');
}
if (response.status >= 500) {
throw new Error('SERVER: The server encountered an error');
}
if (!response.ok) {
throw new Error('HTTP_ERROR: Status ' + response.status);
}
// Parsing errors
try {
const data = await response.json();
return data;
} catch (parseError) {
throw new Error('PARSE: Response is not valid JSON');
}
} catch (error) {
// Network errors -- fetch rejects for these
if (error instanceof TypeError) {
throw new Error('NETWORK: Cannot reach the server. Check your connection.');
}
throw error;
}
}
Try/Catch with Async/Await
The try/catch statement is the primary way to handle errors in async/await code. When an awaited Promise rejects, the rejection is thrown as an exception that can be caught by the surrounding catch block. This gives asynchronous code the same familiar error handling pattern as synchronous code.
Example: Basic Try/Catch Pattern
// Basic pattern -- catch all errors from async operations
async function fetchUserProfile(userId) {
try {
const response = await fetch(
'https://jsonplaceholder.typicode.com/users/' + userId
);
if (!response.ok) {
throw new Error('Server returned status ' + response.status);
}
const user = await response.json();
console.log('User loaded:', user.name);
return user;
} catch (error) {
console.error('Failed to load user profile:', error.message);
return null;
}
}
// Multiple await calls in one try block
async function fetchUserWithPosts(userId) {
try {
const userResponse = await fetch(
'https://jsonplaceholder.typicode.com/users/' + userId
);
if (!userResponse.ok) {
throw new Error('Failed to fetch user');
}
const user = await userResponse.json();
const postsResponse = await fetch(
'https://jsonplaceholder.typicode.com/posts?userId=' + userId
);
if (!postsResponse.ok) {
throw new Error('Failed to fetch posts');
}
const posts = await postsResponse.json();
return { user: user, posts: posts };
} catch (error) {
console.error('Error:', error.message);
return { user: null, posts: [] };
}
}
// Separate try/catch for independent operations
async function loadDashboard() {
let user = null;
let notifications = [];
let stats = null;
try {
const response = await fetch('/api/user/profile');
user = await response.json();
} catch (error) {
console.error('Failed to load profile:', error.message);
}
try {
const response = await fetch('/api/notifications');
notifications = await response.json();
} catch (error) {
console.error('Failed to load notifications:', error.message);
}
try {
const response = await fetch('/api/stats');
stats = await response.json();
} catch (error) {
console.error('Failed to load stats:', error.message);
}
// Dashboard still loads even if some sections fail
return { user, notifications, stats };
}
The Finally Block
The finally block executes regardless of whether the try block succeeded or failed. This is the ideal place to clean up resources, hide loading indicators, or reset state. It runs after both successful completion and after error handling.
Example: Using Finally for Cleanup
async function submitForm(formData) {
const submitButton = document.getElementById('submit-btn');
const spinner = document.getElementById('spinner');
// Disable button and show spinner before the request
submitButton.disabled = true;
spinner.style.display = 'block';
try {
const response = await fetch('/api/forms/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Submission failed');
}
const result = await response.json();
showSuccess('Form submitted successfully!');
return result;
} catch (error) {
showError('Failed to submit: ' + error.message);
return null;
} finally {
// Always re-enable the button and hide spinner
// This runs whether the request succeeded or failed
submitButton.disabled = false;
spinner.style.display = 'none';
}
}
function showSuccess(message) {
console.log('SUCCESS:', message);
}
function showError(message) {
console.error('ERROR:', message);
}
Promise .catch() and .then() Error Handling
When working with Promises directly (without async/await), errors are handled using the .catch() method. Every Promise chain should end with a .catch() to prevent unhandled rejections. You can also pass a second argument to .then() for inline error handling.
Example: Promise Chain Error Handling
// .catch() at the end of a promise chain
fetch('https://jsonplaceholder.typicode.com/posts/1')
.then(function(response) {
if (!response.ok) {
throw new Error('HTTP Error: ' + response.status);
}
return response.json();
})
.then(function(post) {
console.log('Post title:', post.title);
return fetch(
'https://jsonplaceholder.typicode.com/posts/' + post.id + '/comments'
);
})
.then(function(response) {
return response.json();
})
.then(function(comments) {
console.log('Comments:', comments.length);
})
.catch(function(error) {
// Catches errors from ANY step in the chain
console.error('Something went wrong:', error.message);
})
.finally(function() {
console.log('Request complete (success or failure)');
});
// Handling errors at specific points in the chain
fetch('https://jsonplaceholder.typicode.com/users/1')
.then(function(response) {
if (!response.ok) {
throw new Error('Cannot load user');
}
return response.json();
})
.catch(function(error) {
// Handle user fetch error, provide fallback
console.warn('Using default user:', error.message);
return { id: 0, name: 'Guest User', email: 'guest@example.com' };
})
.then(function(user) {
// This runs with either the real user or the fallback
console.log('Welcome,', user.name);
});
// Promise.all -- fails if ANY promise rejects
Promise.all([
fetch('https://jsonplaceholder.typicode.com/users/1').then(r => r.json()),
fetch('https://jsonplaceholder.typicode.com/posts/1').then(r => r.json()),
fetch('https://jsonplaceholder.typicode.com/todos/1').then(r => r.json()),
])
.then(function(results) {
console.log('User:', results[0].name);
console.log('Post:', results[1].title);
console.log('Todo:', results[2].title);
})
.catch(function(error) {
console.error('One of the requests failed:', error.message);
});
// Promise.allSettled -- never rejects, reports all results
Promise.allSettled([
fetch('https://jsonplaceholder.typicode.com/users/1').then(r => r.json()),
fetch('https://api.invalid-domain.com/fail').then(r => r.json()),
fetch('https://jsonplaceholder.typicode.com/todos/1').then(r => r.json()),
])
.then(function(results) {
results.forEach(function(result, index) {
if (result.status === 'fulfilled') {
console.log('Request ' + index + ' succeeded:', result.value);
} else {
console.error('Request ' + index + ' failed:', result.reason.message);
}
});
});
Promise.allSettled() instead of Promise.all() when you want all requests to complete regardless of individual failures. With Promise.all(), a single rejection causes the entire batch to fail. With Promise.allSettled(), you get the result of every promise -- whether it succeeded or failed -- and can handle each individually.Global Error Handlers
Even with careful error handling, unhandled rejections can slip through. JavaScript provides global event handlers that act as a safety net for catching errors you missed. These should be used as a last resort -- not as a replacement for proper try/catch blocks.
Example: Global Error Handlers
// Catch unhandled promise rejections
window.addEventListener('unhandledrejection', function(event) {
console.error(
'Unhandled promise rejection:',
event.reason
);
// Log to your error monitoring service
logErrorToService({
type: 'unhandled_rejection',
message: event.reason.message || String(event.reason),
stack: event.reason.stack || 'No stack trace',
timestamp: new Date().toISOString(),
url: window.location.href,
});
// Prevent the default browser error message
event.preventDefault();
});
// Catch all uncaught JavaScript errors
window.onerror = function(message, source, lineno, colno, error) {
console.error('Uncaught error:', {
message: message,
source: source,
line: lineno,
column: colno,
error: error,
});
logErrorToService({
type: 'uncaught_error',
message: message,
source: source,
line: lineno,
column: colno,
stack: error ? error.stack : 'No stack trace',
timestamp: new Date().toISOString(),
url: window.location.href,
});
// Return true to prevent the default browser error handling
return true;
};
// Simulated error logging service
function logErrorToService(errorData) {
// In production, send this to your error tracking service
console.log('[Error Service] Logging error:', errorData);
// Example: send to a logging endpoint
// fetch('/api/errors', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(errorData),
// }).catch(function() {
// // Silently fail -- do not create an error loop
// });
}
Custom Error Classes
Creating custom error classes allows you to distinguish between different types of errors programmatically. This makes it possible to handle each error type differently -- for example, retrying on network errors but showing a message on validation errors.
Example: Custom Error Hierarchy
// Base API error class
class ApiError extends Error {
constructor(message, status, data) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
class NetworkError extends ApiError {
constructor(message) {
super(message || 'Network connection failed', 0, null);
this.name = 'NetworkError';
this.isRetryable = true;
}
}
class TimeoutError extends ApiError {
constructor(message, timeoutMs) {
super(message || 'Request timed out', 0, null);
this.name = 'TimeoutError';
this.timeoutMs = timeoutMs;
this.isRetryable = true;
}
}
class ValidationError extends ApiError {
constructor(message, fieldErrors) {
super(message || 'Validation failed', 422, fieldErrors);
this.name = 'ValidationError';
this.fieldErrors = fieldErrors;
this.isRetryable = false;
}
}
class AuthenticationError extends ApiError {
constructor(message) {
super(message || 'Authentication required', 401, null);
this.name = 'AuthenticationError';
this.isRetryable = false;
}
}
class ServerError extends ApiError {
constructor(message, status) {
super(message || 'Server error occurred', status || 500, null);
this.name = 'ServerError';
this.isRetryable = true;
}
}
class RateLimitError extends ApiError {
constructor(retryAfterSeconds) {
super('Rate limit exceeded', 429, null);
this.name = 'RateLimitError';
this.retryAfter = retryAfterSeconds;
this.isRetryable = true;
}
}
// Using custom errors in error handling
async function handleApiCall(url, options) {
try {
const data = await fetchWithErrorHandling(url, options);
return data;
} catch (error) {
if (error instanceof AuthenticationError) {
// Redirect to login page
console.log('Redirecting to login...');
} else if (error instanceof ValidationError) {
// Show field-specific error messages
console.log('Fix these fields:', error.fieldErrors);
} else if (error instanceof RateLimitError) {
// Wait and retry
console.log('Retry after', error.retryAfter, 'seconds');
} else if (error.isRetryable) {
// Generic retry for network, timeout, and server errors
console.log('Retrying request...');
} else {
// Unknown error
console.error('Unexpected error:', error);
}
}
}
async function fetchWithErrorHandling(url, options) {
let response;
try {
response = await fetch(url, options);
} catch (fetchError) {
throw new NetworkError('Cannot reach server: ' + fetchError.message);
}
if (response.status === 401) {
throw new AuthenticationError();
}
if (response.status === 422) {
const body = await response.json();
throw new ValidationError('Invalid data', body.errors);
}
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
throw new RateLimitError(retryAfter ? parseInt(retryAfter) : 60);
}
if (response.status >= 500) {
throw new ServerError('Server returned ' + response.status, response.status);
}
if (!response.ok) {
throw new ApiError('Request failed', response.status, null);
}
return await response.json();
}
Retry Logic with Exponential Backoff
When a request fails due to a temporary issue (network glitch, server overload), retrying the request after a delay often succeeds. Exponential backoff means each retry waits longer than the previous one, giving the server time to recover. Adding jitter (randomness) prevents many clients from retrying at the exact same time.
Example: Retry with Exponential Backoff
async function fetchWithRetry(url, options, maxRetries, baseDelay) {
maxRetries = maxRetries || 3;
baseDelay = baseDelay || 1000;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// Do not retry client errors (except 429)
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
const errorBody = await response.text();
throw new Error('Client error ' + response.status + ': ' + errorBody);
}
// Retry on server errors and rate limits
if (!response.ok) {
if (attempt < maxRetries) {
const delay = calculateBackoff(attempt, baseDelay);
console.log(
'Attempt ' + (attempt + 1) + ' failed with status ' +
response.status + '. Retrying in ' + delay + 'ms...'
);
await sleep(delay);
continue;
}
throw new Error('Failed after ' + (maxRetries + 1) + ' attempts: ' + response.status);
}
return await response.json();
} catch (error) {
// Network errors are retryable
if (error instanceof TypeError && attempt < maxRetries) {
const delay = calculateBackoff(attempt, baseDelay);
console.log(
'Network error on attempt ' + (attempt + 1) +
'. Retrying in ' + delay + 'ms...'
);
await sleep(delay);
continue;
}
// If we exhausted retries or hit a non-retryable error
if (attempt === maxRetries) {
throw new Error(
'All ' + (maxRetries + 1) + ' attempts failed. Last error: ' + error.message
);
}
throw error;
}
}
}
function calculateBackoff(attempt, baseDelay) {
// Exponential: 1000, 2000, 4000, 8000...
const exponentialDelay = baseDelay * Math.pow(2, attempt);
// Add jitter: random value between 0 and half the delay
const jitter = Math.random() * exponentialDelay * 0.5;
// Cap at 30 seconds maximum
return Math.min(exponentialDelay + jitter, 30000);
}
function sleep(ms) {
return new Promise(function(resolve) {
setTimeout(resolve, ms);
});
}
// Usage
fetchWithRetry('https://jsonplaceholder.typicode.com/posts/1', {}, 3, 1000)
.then(function(data) {
console.log('Success:', data.title);
})
.catch(function(error) {
console.error('All retries exhausted:', error.message);
});
Circuit Breaker Pattern
The circuit breaker pattern prevents your application from repeatedly calling a failing service. Like an electrical circuit breaker, it "trips" after a threshold of failures and stops making requests for a cooldown period. This protects both your application and the failing server from being overwhelmed with doomed requests.
The circuit breaker has three states:
- Closed -- Normal operation. Requests flow through. Failures are counted.
- Open -- Too many failures. All requests are immediately rejected without calling the server.
- Half-Open -- After the cooldown, one test request is allowed through. If it succeeds, the breaker closes. If it fails, the breaker opens again.
Example: Circuit Breaker Implementation
class CircuitBreaker {
constructor(options) {
this.failureThreshold = options.failureThreshold || 5;
this.resetTimeout = options.resetTimeout || 30000;
this.state = 'CLOSED';
this.failureCount = 0;
this.lastFailureTime = null;
this.successCount = 0;
}
async execute(asyncFunction) {
if (this.state === 'OPEN') {
// Check if cooldown period has passed
var timeSinceFailure = Date.now() - this.lastFailureTime;
if (timeSinceFailure > this.resetTimeout) {
this.state = 'HALF_OPEN';
console.log('Circuit breaker: HALF_OPEN (testing)');
} else {
var waitTime = this.resetTimeout - timeSinceFailure;
throw new Error(
'Circuit breaker is OPEN. Retry in ' +
Math.ceil(waitTime / 1000) + ' seconds.'
);
}
}
try {
var result = await asyncFunction();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
if (this.state === 'HALF_OPEN') {
console.log('Circuit breaker: CLOSED (service recovered)');
}
this.failureCount = 0;
this.state = 'CLOSED';
this.successCount++;
}
onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = 'OPEN';
console.log(
'Circuit breaker: OPEN after ' +
this.failureCount + ' failures'
);
}
}
getStatus() {
return {
state: this.state,
failures: this.failureCount,
successes: this.successCount,
threshold: this.failureThreshold,
};
}
}
// Usage
var apiBreaker = new CircuitBreaker({
failureThreshold: 3,
resetTimeout: 10000,
});
async function fetchWithBreaker(url) {
return apiBreaker.execute(async function() {
var response = await fetch(url);
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.json();
});
}
// Demonstrating the circuit breaker
async function demonstrateBreaker() {
for (var i = 0; i < 6; i++) {
try {
var result = await fetchWithBreaker(
'https://httpstat.us/500'
);
console.log('Success:', result);
} catch (error) {
console.log('Attempt ' + (i + 1) + ':', error.message);
}
}
console.log('Breaker status:', apiBreaker.getStatus());
}
Timeout Implementation
The fetch() API does not have a built-in timeout mechanism by default. If a server hangs, your request could wait indefinitely. You need to implement timeouts yourself using AbortController, which lets you cancel a fetch request after a specified duration.
Example: Fetch with Timeout
async function fetchWithTimeout(url, options, timeoutMs) {
timeoutMs = timeoutMs || 10000;
// AbortController lets us cancel the fetch request
var controller = new AbortController();
var signal = controller.signal;
// Set up the timeout timer
var timeoutId = setTimeout(function() {
controller.abort();
}, timeoutMs);
try {
var fetchOptions = Object.assign({}, options || {}, {
signal: signal,
});
var response = await fetch(url, fetchOptions);
// Clear the timeout since we got a response
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error('HTTP Error: ' + response.status);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new TimeoutError(
'Request timed out after ' + timeoutMs + 'ms',
timeoutMs
);
}
throw error;
}
}
// Usage -- 5 second timeout
fetchWithTimeout(
'https://jsonplaceholder.typicode.com/posts',
{},
5000
)
.then(function(data) {
console.log('Loaded', data.length, 'posts');
})
.catch(function(error) {
if (error instanceof TimeoutError) {
console.error('The server took too long to respond.');
} else {
console.error('Request failed:', error.message);
}
});
// Racing fetch against a timeout using Promise.race
function fetchWithRaceTimeout(url, timeoutMs) {
var fetchPromise = fetch(url).then(function(r) { return r.json(); });
var timeoutPromise = new Promise(function(resolve, reject) {
setTimeout(function() {
reject(new Error('Request timed out after ' + timeoutMs + 'ms'));
}, timeoutMs);
});
return Promise.race([fetchPromise, timeoutPromise]);
}
Graceful Degradation
Graceful degradation means your application continues to function even when some features fail. Instead of showing a blank page or crashing, you provide fallback data, cached content, or reduced functionality. This keeps the user experience acceptable even under poor conditions.
Example: Graceful Degradation Strategies
// Strategy 1: Fallback data
async function getUserProfile(userId) {
var defaultProfile = {
id: userId,
name: 'Unknown User',
email: 'Not available',
avatar: '/images/default-avatar.png',
};
try {
var response = await fetchWithTimeout(
'/api/users/' + userId, {}, 5000
);
return response;
} catch (error) {
console.warn('Using default profile for user ' + userId);
return defaultProfile;
}
}
// Strategy 2: Cached data with stale indicator
class CachedFetcher {
constructor() {
this.cache = new Map();
}
async fetch(url, maxAge) {
maxAge = maxAge || 300000; // 5 minutes default
var cached = this.cache.get(url);
// Try fresh data first
try {
var response = await fetchWithTimeout(url, {}, 5000);
this.cache.set(url, {
data: response,
timestamp: Date.now(),
});
return { data: response, isStale: false };
} catch (error) {
// If we have cached data, use it even if stale
if (cached) {
var age = Date.now() - cached.timestamp;
console.warn(
'Using cached data (' +
Math.round(age / 1000) + 's old) for: ' + url
);
return { data: cached.data, isStale: true };
}
// No cache available, propagate the error
throw error;
}
}
}
// Strategy 3: Progressive loading with partial failures
async function loadPageData() {
var results = await Promise.allSettled([
fetch('/api/hero-content').then(function(r) { return r.json(); }),
fetch('/api/featured-posts').then(function(r) { return r.json(); }),
fetch('/api/sidebar-ads').then(function(r) { return r.json(); }),
fetch('/api/recommendations').then(function(r) { return r.json(); }),
]);
return {
// Critical content -- show error if it fails
hero: results[0].status === 'fulfilled'
? results[0].value
: { error: true, message: 'Content unavailable' },
// Important but not critical -- show empty state
posts: results[1].status === 'fulfilled'
? results[1].value
: [],
// Optional content -- hide if it fails
ads: results[2].status === 'fulfilled'
? results[2].value
: null,
// Optional content -- hide if it fails
recommendations: results[3].status === 'fulfilled'
? results[3].value
: null,
};
}
User-Friendly Error Messages
Technical error messages confuse users. Transform internal errors into clear, actionable messages that tell users what happened and what they can do about it. Never show stack traces, status codes, or internal error details to end users.
Example: User-Friendly Error Mapping
var errorMessages = {
NetworkError: {
title: 'Connection Problem',
message: 'Please check your internet connection and try again.',
action: 'retry',
},
TimeoutError: {
title: 'Slow Response',
message: 'The server is taking too long to respond. Please try again in a moment.',
action: 'retry',
},
AuthenticationError: {
title: 'Session Expired',
message: 'Your session has expired. Please sign in again to continue.',
action: 'login',
},
ValidationError: {
title: 'Invalid Data',
message: 'Please check your input and correct the highlighted fields.',
action: 'fix',
},
RateLimitError: {
title: 'Too Many Requests',
message: 'You are making requests too quickly. Please wait a moment and try again.',
action: 'wait',
},
ServerError: {
title: 'Server Problem',
message: 'Something went wrong on our end. We have been notified and are working on it.',
action: 'retry',
},
default: {
title: 'Something Went Wrong',
message: 'An unexpected error occurred. Please try again or contact support.',
action: 'retry',
},
};
function getUserFriendlyError(error) {
var errorInfo = errorMessages[error.name] || errorMessages.default;
return errorInfo;
}
function displayError(error, containerId) {
var info = getUserFriendlyError(error);
var container = document.getElementById(containerId);
var html = '<div class="error-card">';
html += ' <h3>' + info.title + '</h3>';
html += ' <p>' + info.message + '</p>';
if (info.action === 'retry') {
html += ' <button class="retry-btn">Try Again</button>';
} else if (info.action === 'login') {
html += ' <button class="login-btn">Sign In</button>';
}
html += '</div>';
container.innerHTML = html;
// Log the technical details for debugging
console.error('[Error Details]', {
name: error.name,
message: error.message,
status: error.status,
stack: error.stack,
});
}
Error Logging and Monitoring
In production, you need to know when errors happen so you can fix them. An error logging service collects error data from all users and alerts you to problems. Build a centralized logger that captures context information alongside the error itself.
Example: Error Logger
class ErrorLogger {
constructor(options) {
this.endpoint = options.endpoint || '/api/errors';
this.appVersion = options.appVersion || '1.0.0';
this.buffer = [];
this.bufferSize = options.bufferSize || 10;
this.flushInterval = options.flushInterval || 30000;
// Flush buffer periodically
var self = this;
setInterval(function() {
self.flush();
}, this.flushInterval);
}
log(error, context) {
var entry = {
timestamp: new Date().toISOString(),
appVersion: this.appVersion,
url: window.location.href,
userAgent: navigator.userAgent,
error: {
name: error.name || 'Error',
message: error.message,
stack: error.stack || 'No stack trace',
status: error.status || null,
},
context: context || {},
};
this.buffer.push(entry);
console.error('[Logger]', entry.error.name + ':', entry.error.message);
// Flush if buffer is full
if (this.buffer.length >= this.bufferSize) {
this.flush();
}
}
async flush() {
if (this.buffer.length === 0) return;
var entries = this.buffer.slice();
this.buffer = [];
try {
await fetch(this.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ errors: entries }),
});
} catch (sendError) {
// Put entries back if sending failed
this.buffer = entries.concat(this.buffer);
console.warn('Failed to send error logs. Will retry.');
}
}
}
// Create a global logger instance
var logger = new ErrorLogger({
endpoint: '/api/error-logs',
appVersion: '2.1.0',
bufferSize: 5,
});
// Use the logger in your API calls
async function fetchWithLogging(url, context) {
try {
var response = await fetch(url);
if (!response.ok) {
var error = new Error('HTTP ' + response.status);
error.status = response.status;
throw error;
}
return await response.json();
} catch (error) {
logger.log(error, {
url: url,
action: context || 'api_call',
});
throw error;
}
}
Error Boundaries Concept
An error boundary is a pattern where you wrap sections of your application in protective containers that catch errors and prevent them from crashing the entire page. While this concept is most commonly associated with React, the underlying principle applies to any JavaScript application. Each section of your page should be isolated so that an error in one section does not bring down the others.
Example: Error Boundary Pattern in Vanilla JavaScript
class WidgetErrorBoundary {
constructor(containerId, widgetName) {
this.container = document.getElementById(containerId);
this.widgetName = widgetName;
this.hasError = false;
}
async render(renderFunction) {
try {
this.hasError = false;
await renderFunction(this.container);
} catch (error) {
this.hasError = true;
console.error(
'Error in widget "' + this.widgetName + '":',
error.message
);
this.renderFallback(error);
}
}
renderFallback(error) {
this.container.innerHTML =
'<div class="widget-error">' +
' <p>This section could not be loaded.</p>' +
' <button class="widget-retry">Retry</button>' +
'</div>';
var self = this;
this.container.querySelector('.widget-retry')
.addEventListener('click', function() {
self.render(self.lastRenderFunction);
});
this.lastRenderFunction = null;
}
wrap(renderFunction) {
this.lastRenderFunction = renderFunction;
return this.render(renderFunction);
}
}
// Usage: each section of the page is isolated
async function initializeDashboard() {
var profileBoundary = new WidgetErrorBoundary(
'profile-section', 'User Profile'
);
var postsBoundary = new WidgetErrorBoundary(
'posts-section', 'Recent Posts'
);
var statsBoundary = new WidgetErrorBoundary(
'stats-section', 'Statistics'
);
// Each section loads independently
// If posts fail, profile and stats still work
await Promise.allSettled([
profileBoundary.wrap(async function(container) {
var response = await fetch('/api/profile');
var profile = await response.json();
container.innerHTML = '<h2>' + profile.name + '</h2>';
}),
postsBoundary.wrap(async function(container) {
var response = await fetch('/api/posts');
var posts = await response.json();
var html = '';
posts.forEach(function(post) {
html += '<div>' + post.title + '</div>';
});
container.innerHTML = html;
}),
statsBoundary.wrap(async function(container) {
var response = await fetch('/api/stats');
var stats = await response.json();
container.innerHTML = '<p>Views: ' + stats.views + '</p>';
}),
]);
}
Building a Robust API Wrapper
Let us bring together all the patterns from this lesson into a production-ready API wrapper. This class combines custom errors, retry logic, timeouts, circuit breaking, logging, and user-friendly error handling into a single reusable module.
Example: Production-Ready API Wrapper
class RobustApiClient {
constructor(baseUrl, options) {
this.baseUrl = baseUrl;
this.timeout = (options && options.timeout) || 10000;
this.maxRetries = (options && options.maxRetries) || 3;
this.baseDelay = (options && options.baseDelay) || 1000;
this.breaker = new CircuitBreaker({
failureThreshold: (options && options.breakerThreshold) || 5,
resetTimeout: (options && options.breakerReset) || 30000,
});
this.logger = (options && options.logger) || null;
this.defaultHeaders = {
'Content-Type': 'application/json',
};
}
setAuthToken(token) {
this.defaultHeaders['Authorization'] = 'Bearer ' + token;
}
async request(method, endpoint, body, options) {
var url = this.baseUrl + endpoint;
var self = this;
// Use circuit breaker
return this.breaker.execute(async function() {
return await self.executeWithRetry(method, url, body, options);
});
}
async executeWithRetry(method, url, body, options) {
var lastError;
var retries = (options && options.retries !== undefined)
? options.retries : this.maxRetries;
for (var attempt = 0; attempt <= retries; attempt++) {
try {
var result = await this.executeSingleRequest(
method, url, body, options
);
return result;
} catch (error) {
lastError = error;
// Do not retry non-retryable errors
if (!error.isRetryable) {
throw error;
}
// Do not retry if we exhausted attempts
if (attempt < retries) {
var delay = this.calculateBackoff(attempt);
console.log(
'Retry ' + (attempt + 1) + '/' + retries +
' in ' + delay + 'ms...'
);
await this.sleep(delay);
}
}
}
throw lastError;
}
async executeSingleRequest(method, url, body, options) {
var controller = new AbortController();
var timeout = (options && options.timeout) || this.timeout;
var timeoutId = setTimeout(function() {
controller.abort();
}, timeout);
try {
var fetchOptions = {
method: method,
headers: Object.assign({}, this.defaultHeaders),
signal: controller.signal,
};
if (body && method !== 'GET') {
fetchOptions.body = JSON.stringify(body);
}
var response;
try {
response = await fetch(url, fetchOptions);
} catch (fetchError) {
if (fetchError.name === 'AbortError') {
throw new TimeoutError(
'Request timed out after ' + timeout + 'ms',
timeout
);
}
throw new NetworkError(fetchError.message);
}
return await this.processResponse(response);
} finally {
clearTimeout(timeoutId);
}
}
async processResponse(response) {
if (response.status === 204) {
return null;
}
if (response.status === 401) {
throw new AuthenticationError('Session expired');
}
if (response.status === 422) {
var validationBody = await response.json();
throw new ValidationError('Invalid data', validationBody.errors);
}
if (response.status === 429) {
var retryHeader = response.headers.get('Retry-After');
throw new RateLimitError(
retryHeader ? parseInt(retryHeader) : 60
);
}
if (response.status >= 500) {
throw new ServerError(
'Server error: ' + response.status,
response.status
);
}
if (!response.ok) {
var errorText = await response.text();
var apiError = new ApiError(
errorText || 'Request failed',
response.status,
null
);
apiError.isRetryable = false;
throw apiError;
}
try {
return await response.json();
} catch (parseError) {
throw new ApiError(
'Invalid JSON response',
response.status,
null
);
}
}
calculateBackoff(attempt) {
var delay = this.baseDelay * Math.pow(2, attempt);
var jitter = Math.random() * delay * 0.5;
return Math.min(delay + jitter, 30000);
}
sleep(ms) {
return new Promise(function(resolve) {
setTimeout(resolve, ms);
});
}
// Convenience methods
async get(endpoint, options) {
return this.request('GET', endpoint, null, options);
}
async post(endpoint, body, options) {
return this.request('POST', endpoint, body, options);
}
async put(endpoint, body, options) {
return this.request('PUT', endpoint, body, options);
}
async patch(endpoint, body, options) {
return this.request('PATCH', endpoint, body, options);
}
async delete(endpoint, options) {
return this.request('DELETE', endpoint, null, options);
}
}
// Usage
var api = new RobustApiClient('https://jsonplaceholder.typicode.com', {
timeout: 8000,
maxRetries: 3,
baseDelay: 1000,
breakerThreshold: 5,
breakerReset: 30000,
});
// Simple GET request -- retries, timeouts, and circuit
// breaking happen automatically behind the scenes
async function loadData() {
try {
var posts = await api.get('/posts');
console.log('Loaded', posts.length, 'posts');
var newPost = await api.post('/posts', {
title: 'New Post',
body: 'Content here.',
userId: 1,
});
console.log('Created post:', newPost.id);
} catch (error) {
var friendlyError = getUserFriendlyError(error);
console.log(friendlyError.title + ':', friendlyError.message);
}
}
loadData();
https://httpstat.us/500 to test specific HTTP status codes. Error handling code that is never tested is error handling code that does not work.Practice Exercise
Build a robust news reader application that fetches posts from the JSONPlaceholder API and demonstrates every error handling pattern covered in this lesson. Your application should include the following: create custom error classes for NetworkError, TimeoutError, and ServerError, each with an isRetryable property; implement a fetchWithTimeout function using AbortController that throws your custom TimeoutError after 5 seconds; build a retry function with exponential backoff that retries up to 3 times with delays of 1 second, 2 seconds, and 4 seconds, but only retries errors marked as retryable; implement a CircuitBreaker class that opens after 3 failures and resets after 15 seconds; create a CachedFetcher that returns stale cached data when fresh requests fail; build an ErrorLogger class that buffers errors and logs them to the console with timestamps; wrap your page in three independent WidgetErrorBoundary sections (header with user info, main content with posts, sidebar with statistics) so that a failure in one section does not affect the others; map all error types to user-friendly messages with appropriate action buttons (retry, sign in, or wait); and finally, test your application by using the browser developer tools to simulate going offline, throttling the network to Slow 3G, and blocking specific API endpoints to verify that each error handling pattern works correctly.