Error Handling Strategies in Modern JavaScript Applications
Build robust applications with proper error handling. Learn try-catch, error boundaries, and advanced error management techniques.
Error handling is one of the most critical aspects of building robust JavaScript applications. Poor error handling can lead to crashes, data loss, security vulnerabilities, and frustrated users. In this comprehensive guide, we'll explore modern error handling strategies that will help you build resilient applications that gracefully handle unexpected situations.
Understanding JavaScript Errors
Before diving into handling strategies, let's understand the types of errors you'll encounter in JavaScript:
// 1. Syntax Errors (caught at parse time)
// console.log("Hello World" // Missing closing parenthesis
// 2. Reference Errors (accessing undefined variables)
try {
console.log(undefinedVariable)
} catch (error) {
console.log(error.name) // "ReferenceError"
}
// 3. Type Errors (wrong data type operations)
try {
null.someMethod()
} catch (error) {
console.log(error.name) // "TypeError"
}
// 4. Range Errors (values outside valid range)
try {
new Array(-1)
} catch (error) {
console.log(error.name) // "RangeError"
}
// 5. Custom Errors
class ValidationError extends Error {
constructor(message, field) {
super(message)
this.name = 'ValidationError'
this.field = field
}
}
Try-Catch Fundamentals
Basic Try-Catch Structure
function divideNumbers(a, b) {
try {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('Both arguments must be numbers')
}
if (b === 0) {
throw new Error('Division by zero is not allowed')
}
return a / b
} catch (error) {
console.error('Error in divideNumbers:', error.message)
// Handle different error types
if (error instanceof TypeError) {
return { error: 'Invalid input types' }
} else if (error.message.includes('Division by zero')) {
return { error: 'Cannot divide by zero' }
}
return { error: 'Unknown error occurred' }
} finally {
// This block always executes
console.log('Division operation completed')
}
}
// Usage
console.log(divideNumbers(10, 2)) // 5
console.log(divideNumbers(10, 0)) // { error: 'Cannot divide by zero' }
console.log(divideNumbers('10', 2)) // { error: 'Invalid input types' }
Advanced Try-Catch Patterns
// Nested try-catch for granular error handling
function processUserData(userData) {
try {
// Validate user data
try {
validateUserData(userData)
} catch (validationError) {
throw new ValidationError(`Invalid user data: ${validationError.message}`)
}
// Process the data
try {
return transformUserData(userData)
} catch (transformError) {
throw new Error(`Data transformation failed: ${transformError.message}`)
}
} catch (error) {
// Log the error for debugging
console.error('User data processing failed:', error)
// Return user-friendly error
if (error instanceof ValidationError) {
return { success: false, error: 'Please check your input data' }
}
return { success: false, error: 'Processing failed. Please try again.' }
}
}
// Error recovery pattern
function robustApiCall(url, options = {}) {
const maxRetries = options.maxRetries || 3
const retryDelay = options.retryDelay || 1000
async function attemptCall(attempt = 1) {
try {
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.warn(`Attempt ${attempt} failed:`, error.message)
if (attempt >= maxRetries) {
throw new Error(`Failed after ${maxRetries} attempts: ${error.message}`)
}
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, retryDelay * attempt))
return attemptCall(attempt + 1)
}
}
return attemptCall()
}
Async/Await Error Handling
Basic Async Error Handling
// Basic async/await error handling
async function fetchUserProfile(userId) {
try {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`)
}
const user = await response.json()
return { success: true, data: user }
} catch (error) {
console.error('Error fetching user profile:', error)
return { success: false, error: error.message }
}
}
// Multiple async operations
async function loadUserDashboard(userId) {
try {
// Run operations in parallel
const [user, posts, notifications] = await Promise.all([
fetchUserProfile(userId),
fetchUserPosts(userId),
fetchUserNotifications(userId),
])
// Check if any operation failed
if (!user.success) throw new Error('Failed to load user profile')
if (!posts.success) throw new Error('Failed to load user posts')
if (!notifications.success) throw new Error('Failed to load notifications')
return {
success: true,
data: {
user: user.data,
posts: posts.data,
notifications: notifications.data,
},
}
} catch (error) {
console.error('Dashboard loading failed:', error)
return { success: false, error: error.message }
}
}
Advanced Async Error Patterns
// Error boundary for async operations
class AsyncErrorBoundary {
constructor() {
this.errors = []
this.onError = null
}
async execute(asyncFunction, context = 'Unknown') {
try {
return await asyncFunction()
} catch (error) {
const errorInfo = {
error,
context,
timestamp: new Date().toISOString(),
stack: error.stack,
}
this.errors.push(errorInfo)
if (this.onError) {
this.onError(errorInfo)
}
throw error
}
}
getErrors() {
return this.errors
}
clearErrors() {
this.errors = []
}
}
// Usage
const errorBoundary = new AsyncErrorBoundary()
errorBoundary.onError = (errorInfo) => {
// Send to logging service
console.error('Async error caught:', errorInfo)
}
// Timeout wrapper for async operations
function withTimeout(
promise,
timeoutMs,
timeoutMessage = 'Operation timed out',
) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs)
})
return Promise.race([promise, timeoutPromise])
}
// Circuit breaker pattern
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.threshold = threshold
this.timeout = timeout
this.failureCount = 0
this.lastFailureTime = null
this.state = 'CLOSED' // CLOSED, OPEN, HALF_OPEN
}
async execute(operation) {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.timeout) {
this.state = 'HALF_OPEN'
} else {
throw new Error('Circuit breaker is OPEN')
}
}
try {
const result = await operation()
this.onSuccess()
return result
} catch (error) {
this.onFailure()
throw error
}
}
onSuccess() {
this.failureCount = 0
this.state = 'CLOSED'
}
onFailure() {
this.failureCount++
this.lastFailureTime = Date.now()
if (this.failureCount >= this.threshold) {
this.state = 'OPEN'
}
}
}
Promise Error Handling
Promise.catch() and Error Propagation
// Basic promise error handling
function fetchData(url) {
return fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.json()
})
.catch((error) => {
console.error('Fetch error:', error)
throw error // Re-throw to allow caller to handle
})
}
// Promise chain error handling
function processDataPipeline(url) {
return fetchData(url)
.then((data) => validateData(data))
.then((validData) => transformData(validData))
.then((transformedData) => saveData(transformedData))
.catch((error) => {
// Handle errors from any step in the chain
if (error.name === 'ValidationError') {
return { error: 'Data validation failed' }
} else if (error.name === 'TransformError') {
return { error: 'Data transformation failed' }
} else if (error.name === 'SaveError') {
return { error: 'Failed to save data' }
}
return { error: 'Unknown error in data pipeline' }
})
}
// Promise.allSettled for handling multiple operations
async function fetchMultipleResources(urls) {
const promises = urls.map((url) =>
fetch(url).then((response) => {
if (!response.ok) throw new Error(`Failed to fetch ${url}`)
return response.json()
}),
)
const results = await Promise.allSettled(promises)
const successful = results
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value)
const failed = results
.filter((result) => result.status === 'rejected')
.map((result) => result.reason.message)
return { successful, failed }
}
Custom Error Classes
Creating Meaningful Error Types
// Base application error
class AppError extends Error {
constructor(message, statusCode = 500, isOperational = true) {
super(message)
this.name = this.constructor.name
this.statusCode = statusCode
this.isOperational = isOperational
this.timestamp = new Date().toISOString()
Error.captureStackTrace(this, this.constructor)
}
}
// Specific error types
class ValidationError extends AppError {
constructor(message, field) {
super(message, 400)
this.field = field
this.type = 'VALIDATION_ERROR'
}
}
class AuthenticationError extends AppError {
constructor(message = 'Authentication failed') {
super(message, 401)
this.type = 'AUTHENTICATION_ERROR'
}
}
class AuthorizationError extends AppError {
constructor(message = 'Access denied') {
super(message, 403)
this.type = 'AUTHORIZATION_ERROR'
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404)
this.type = 'NOT_FOUND_ERROR'
}
}
class NetworkError extends AppError {
constructor(message = 'Network request failed') {
super(message, 0)
this.type = 'NETWORK_ERROR'
}
}
// Error factory
class ErrorFactory {
static createValidationError(field, value, rule) {
return new ValidationError(
`Validation failed for field '${field}': ${rule}`,
field,
)
}
static createNetworkError(url, status) {
return new NetworkError(
`Network request to ${url} failed with status ${status}`,
)
}
static fromHttpResponse(response) {
switch (response.status) {
case 400:
return new ValidationError('Bad request')
case 401:
return new AuthenticationError()
case 403:
return new AuthorizationError()
case 404:
return new NotFoundError()
default:
return new AppError(`HTTP error: ${response.status}`)
}
}
}
Error Logging and Monitoring
Comprehensive Error Logging
// Error logger utility
class ErrorLogger {
constructor(config = {}) {
this.config = {
logLevel: 'error',
includeStack: true,
includeUserAgent: true,
includeUrl: true,
maxLogSize: 1000,
...config,
}
this.logs = []
}
log(error, context = {}) {
const logEntry = {
timestamp: new Date().toISOString(),
message: error.message,
name: error.name,
stack: this.config.includeStack ? error.stack : undefined,
context,
userAgent: this.config.includeUserAgent ? navigator.userAgent : undefined,
url: this.config.includeUrl ? window.location.href : undefined,
userId: context.userId,
sessionId: context.sessionId,
}
this.logs.push(logEntry)
// Keep logs within size limit
if (this.logs.length > this.config.maxLogSize) {
this.logs = this.logs.slice(-this.config.maxLogSize)
}
// Send to external service
this.sendToService(logEntry)
// Console logging for development
if (process.env.NODE_ENV === 'development') {
console.error('Error logged:', logEntry)
}
}
async sendToService(logEntry) {
try {
await fetch('/api/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(logEntry),
})
} catch (sendError) {
console.error('Failed to send error log:', sendError)
}
}
getLogs() {
return this.logs
}
clearLogs() {
this.logs = []
}
}
// Global error handler
class GlobalErrorHandler {
constructor(logger) {
this.logger = logger
this.setupGlobalHandlers()
}
setupGlobalHandlers() {
// Unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
this.logger.log(event.reason, {
type: 'unhandled_promise_rejection',
promise: event.promise,
})
// Prevent default browser behavior
event.preventDefault()
})
// Global error handler
window.addEventListener('error', (event) => {
this.logger.log(event.error || new Error(event.message), {
type: 'global_error',
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
})
})
// Resource loading errors
window.addEventListener(
'error',
(event) => {
if (event.target !== window) {
this.logger.log(
new Error(
`Resource failed to load: ${
event.target.src || event.target.href
}`,
),
{
type: 'resource_error',
element: event.target.tagName,
source: event.target.src || event.target.href,
},
)
}
},
true,
)
}
}
// Usage
const errorLogger = new ErrorLogger({
logLevel: 'error',
includeStack: true,
})
const globalErrorHandler = new GlobalErrorHandler(errorLogger)
User-Friendly Error Handling
Error UI Components
// Error display component
class ErrorDisplay {
constructor(container) {
this.container = container
this.currentError = null
}
show(error, options = {}) {
this.currentError = error
const errorElement = this.createErrorElement(error, options)
this.container.innerHTML = ''
this.container.appendChild(errorElement)
// Auto-hide after delay if specified
if (options.autoHide) {
setTimeout(() => this.hide(), options.autoHide)
}
}
createErrorElement(error, options) {
const errorDiv = document.createElement('div')
errorDiv.className = `error-display ${options.type || 'error'}`
const message = this.getUserFriendlyMessage(error)
errorDiv.innerHTML = `
<div class="error-content">
<div class="error-icon">${this.getErrorIcon(error)}</div>
<div class="error-message">${message}</div>
${
options.showRetry
? '<button class="retry-button">Try Again</button>'
: ''
}
<button class="close-button">ร</button>
</div>
`
// Add event listeners
const closeButton = errorDiv.querySelector('.close-button')
closeButton.addEventListener('click', () => this.hide())
if (options.showRetry) {
const retryButton = errorDiv.querySelector('.retry-button')
retryButton.addEventListener('click', () => {
this.hide()
if (options.onRetry) options.onRetry()
})
}
return errorDiv
}
getUserFriendlyMessage(error) {
// Map technical errors to user-friendly messages
const messageMap = {
NetworkError: 'Please check your internet connection and try again.',
ValidationError: 'Please check your input and try again.',
AuthenticationError: 'Please log in to continue.',
AuthorizationError: "You don't have permission to perform this action.",
NotFoundError: 'The requested item could not be found.',
TimeoutError: 'The request took too long. Please try again.',
}
return messageMap[error.name] || 'Something went wrong. Please try again.'
}
getErrorIcon(error) {
const iconMap = {
NetworkError: '๐',
ValidationError: 'โ ๏ธ',
AuthenticationError: '๐',
AuthorizationError: '๐ซ',
NotFoundError: '๐',
TimeoutError: 'โฑ๏ธ',
}
return iconMap[error.name] || 'โ'
}
hide() {
this.container.innerHTML = ''
this.currentError = null
}
}
// Form validation with error handling
class FormValidator {
constructor(form, errorDisplay) {
this.form = form
this.errorDisplay = errorDisplay
this.rules = new Map()
}
addRule(fieldName, validator, message) {
if (!this.rules.has(fieldName)) {
this.rules.set(fieldName, [])
}
this.rules.get(fieldName).push({ validator, message })
}
async validate() {
const errors = []
for (const [fieldName, rules] of this.rules) {
const field = this.form.querySelector(`[name="${fieldName}"]`)
const value = field ? field.value : ''
for (const rule of rules) {
try {
const isValid = await rule.validator(value, this.form)
if (!isValid) {
errors.push({
field: fieldName,
message: rule.message,
})
break // Stop at first error for this field
}
} catch (error) {
errors.push({
field: fieldName,
message: 'Validation error occurred',
})
}
}
}
if (errors.length > 0) {
this.displayErrors(errors)
return false
}
this.clearErrors()
return true
}
displayErrors(errors) {
// Clear previous errors
this.clearErrors()
// Display field-specific errors
errors.forEach((error) => {
const field = this.form.querySelector(`[name="${error.field}"]`)
if (field) {
field.classList.add('error')
const errorElement = document.createElement('div')
errorElement.className = 'field-error'
errorElement.textContent = error.message
field.parentNode.appendChild(errorElement)
}
})
// Show general error message
const generalError = new ValidationError(
`Please fix ${errors.length} error${
errors.length > 1 ? 's' : ''
} and try again.`,
)
this.errorDisplay.show(generalError, {
type: 'warning',
autoHide: 5000,
})
}
clearErrors() {
// Remove error classes and messages
this.form.querySelectorAll('.error').forEach((field) => {
field.classList.remove('error')
})
this.form.querySelectorAll('.field-error').forEach((error) => {
error.remove()
})
}
}
Cancellation and Timeout Patterns
Using AbortController for Cancellable Operations
// Cancellable fetch operations using AbortController
class ApiClient {
constructor() {
this.activeRequests = new Map()
}
async fetchWithAbort(url, options = {}) {
const controller = new AbortController()
const timeoutId = setTimeout(
() => controller.abort(),
options.timeout || 5000,
)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return await response.json()
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('Request was cancelled or timed out')
}
throw error
} finally {
clearTimeout(timeoutId)
}
}
// Cancel specific request
async fetchWithCancellation(url, requestId) {
const controller = new AbortController()
this.activeRequests.set(requestId, controller)
try {
const response = await fetch(url, { signal: controller.signal })
return await response.json()
} catch (error) {
if (error.name === 'AbortError') {
console.log(`Request ${requestId} was cancelled`)
return null
}
throw error
} finally {
this.activeRequests.delete(requestId)
}
}
cancelRequest(requestId) {
const controller = this.activeRequests.get(requestId)
if (controller) {
controller.abort()
}
}
cancelAllRequests() {
this.activeRequests.forEach((controller) => controller.abort())
this.activeRequests.clear()
}
}
// Usage example
const apiClient = new ApiClient()
// Request with timeout
try {
const data = await apiClient.fetchWithAbort('/api/slow-endpoint', {
timeout: 3000,
})
console.log('Data received:', data)
} catch (error) {
console.error('Request failed:', error.message)
}
// Cancellable request
const requestId = 'user-search'
apiClient.fetchWithCancellation('/api/search', requestId)
// Cancel if user types more
setTimeout(() => {
apiClient.cancelRequest(requestId)
}, 1000)
Implementing Retry Logic with Cancellation
async function fetchWithRetryAndAbort(url, options = {}) {
const { maxRetries = 3, retryDelay = 1000, timeout = 5000 } = options
const controller = new AbortController()
// Set up overall timeout
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, {
signal: controller.signal,
...options,
})
if (response.ok) {
return await response.json()
}
// Don't retry for client errors (4xx)
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error: ${response.status}`)
}
// Retry for server errors (5xx)
if (attempt === maxRetries) {
throw new Error(`Server error after ${maxRetries} attempts`)
}
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('Request timeout or cancelled')
}
if (attempt === maxRetries) {
throw error
}
// Wait before retry, but check if aborted
await new Promise((resolve, reject) => {
const delayTimeout = setTimeout(resolve, retryDelay * attempt)
controller.signal.addEventListener('abort', () => {
clearTimeout(delayTimeout)
reject(new Error('Request cancelled during retry delay'))
})
})
}
}
} finally {
clearTimeout(timeoutId)
}
}
// Advanced cancellation with cleanup
class CancellableOperation {
constructor() {
this.controller = new AbortController()
this.cleanupTasks = []
}
get signal() {
return this.controller.signal
}
addCleanupTask(task) {
this.cleanupTasks.push(task)
}
async execute(operation) {
try {
return await operation(this.signal)
} catch (error) {
if (error.name === 'AbortError') {
console.log('Operation was cancelled')
return null
}
throw error
} finally {
await this.cleanup()
}
}
cancel() {
this.controller.abort()
}
async cleanup() {
for (const task of this.cleanupTasks) {
try {
await task()
} catch (error) {
console.error('Cleanup task failed:', error)
}
}
this.cleanupTasks.length = 0
}
}
// Usage
const operation = new CancellableOperation()
operation.addCleanupTask(() => {
console.log('Cleaning up resources...')
})
const result = await operation.execute(async (signal) => {
const response = await fetch('/api/data', { signal })
return response.json()
})
Testing Error Scenarios
Error Testing Utilities
// Error simulation for testing
class ErrorSimulator {
constructor() {
this.originalFetch = window.fetch
this.originalConsoleError = console.error
this.simulatedErrors = new Map()
}
simulateNetworkError(url, errorType = 'network') {
this.simulatedErrors.set(url, errorType)
}
simulateHttpError(url, status) {
this.simulatedErrors.set(url, { type: 'http', status })
}
enable() {
window.fetch = async (url, options) => {
const simulation = this.simulatedErrors.get(url)
if (simulation) {
if (simulation === 'network') {
throw new NetworkError(`Simulated network error for ${url}`)
} else if (simulation.type === 'http') {
return {
ok: false,
status: simulation.status,
statusText: 'Simulated Error',
json: () => Promise.resolve({ error: 'Simulated error' }),
}
}
}
return this.originalFetch(url, options)
}
}
disable() {
window.fetch = this.originalFetch
this.simulatedErrors.clear()
}
suppressConsoleErrors() {
console.error = () => {} // Suppress error logs during testing
}
restoreConsoleErrors() {
console.error = this.originalConsoleError
}
}
// Test helper functions
function expectError(asyncFunction, expectedErrorType) {
return asyncFunction()
.then(() => {
throw new Error('Expected function to throw an error')
})
.catch((error) => {
if (error.constructor.name !== expectedErrorType) {
throw new Error(
`Expected ${expectedErrorType}, but got ${error.constructor.name}`,
)
}
return error
})
}
// Example test
async function testErrorHandling() {
const simulator = new ErrorSimulator()
simulator.enable()
simulator.suppressConsoleErrors()
try {
// Test network error
simulator.simulateNetworkError('/api/users')
await expectError(() => fetchUserProfile(1), 'NetworkError')
// Test HTTP error
simulator.simulateHttpError('/api/users/1', 404)
await expectError(() => fetchUserProfile(1), 'NotFoundError')
console.log('All error handling tests passed!')
} finally {
simulator.disable()
simulator.restoreConsoleErrors()
}
}
Conclusion
Effective error handling is crucial for building robust JavaScript applications. By implementing comprehensive error handling strategies, you can:
Key takeaways:
- Use try-catch blocks appropriately for synchronous and asynchronous code
- Create custom error classes for better error categorization
- Implement proper error logging and monitoring
- Provide user-friendly error messages and recovery options
- Test error scenarios thoroughly
- Use patterns like circuit breakers and retry logic for resilience
- Handle both expected and unexpected errors gracefully
Remember that good error handling is not just about catching errorsโit's about providing meaningful feedback, enabling recovery, and maintaining a positive user experience even when things go wrong.
Build resilient applications that your users can trust! ๐