Building Resilient JavaScript Applications: 7 Error Handling Strategies
Errors are inevitable in software development. I’ve learned that how we handle them defines our application’s resilience. Over years of working with JavaScript, I’ve identified patterns that transform catastrophic failures into manageable events. These approaches keep applications running smoothly when unexpected issues arise.
Structured Try-Catch Blocks
When working with synchronous operations, I wrap critical sections in try-catch blocks. This approach localizes error containment while maintaining code flow. I always catch specific error types first - it’s like having different safety nets for different acrobats. The finally block is my cleanup crew, ensuring resources get released regardless of success or failure. Consider this file processing example:
function readConfigFile(path) {
let fileHandle;
try {
fileHandle = fs.openSync(path, 'r');
const data = fs.readFileSync(fileHandle);
return parseConfig(data);
} catch (err) {
if (err instanceof FileNotFoundError) {
generateDefaultConfig();
return defaultConfig;
}
if (err instanceof SyntaxError) {
logConfigError(`Malformed config: ${path}`);
}
throw new ConfigReadError('Critical failure', err);
} finally {
if (fileHandle) {
fs.closeSync(fileHandle);
}
}
}
The finally block guarantees file closure even when parseConfig() fails. I’ve prevented countless resource leaks with this simple discipline.
Promise Error Propagation
Promises create asynchronous error pipelines. I treat .catch() as a transformation stage - not just an error dump. By rethrowing wrapped errors, I maintain context while adding diagnostic details. This pattern shines in data processing workflows:
const processOrder = (orderId) => {
fetchOrder(orderId)
.then(validateInventory)
.then(reserveItems)
.catch(handleRetryableError)
.then(generateShippingLabel)
.catch(shippingError => {
notifyWarehouseTeam(shippingError);
throw new FulfillmentError(orderId, shippingError);
});
};
function handleRetryableError(err) {
if (err instanceof NetworkTimeoutError) {
return retryOperation(3, 1000);
}
throw err;
}
Each catch block handles specific failure scenarios. The final catch converts low-level errors into domain-specific exceptions, preserving the order context.
Async/Await Error Wrapping
I combine async/await with try-catch for readable asynchronous code. To avoid repetitive patterns, I create error-wrapping utilities. This example shows how I handle database operations:
async function getUserTransactions(userId) {
try {
const connection = await db.connect();
const transactions = await connection.query(
`SELECT * FROM transactions WHERE user_id = $1`,
[userId]
);
return enrichTransactions(transactions);
} catch (dbError) {
if (dbError instanceof ConnectionTimeoutError) {
return getCachedTransactions(userId);
}
throw new TransactionError('Database failure', dbError);
}
}
// Utility function
async function withDatabaseErrorHandling(operation) {
try {
return await operation();
} catch (error) {
if (error.code === 'ECONNREFUSED') {
throw new DatabaseUnavailableError();
}
throw new DatabaseOperationError(error);
}
}
The wrapper standardizes database error handling across the codebase. I’ve reduced redundant error checks by 70% in projects using this technique.
Global Error Handlers
Last-line defenses save applications from complete failure. I configure global handlers differently for browsers and Node.js. Here’s how I implement them:
// Browser implementation
window.addEventListener('error', (event) => {
captureError({
message: event.message,
stack: event.error.stack,
component: window.location.pathname
});
displayGracefulDegradationUI();
});
window.addEventListener('unhandledrejection', (event) => {
logPromiseRejection(event.reason);
event.preventDefault(); // Prevent console logging
});
// Node.js implementation
process.on('uncaughtException', (error) => {
emergencyLogger.write(`CRITICAL: ${error.stack}`);
process.exit(1); // Fail fast in production
});
process.on('unhandledRejection', (reason) => {
monitorService.report(`UNHANDLED_REJECTION: ${reason}`);
});
In browsers, I focus on preserving user experience. In Node.js, I prioritize logging and controlled shutdowns. Always remember: global handlers are safety nets, not primary error handling.
Custom Error Classes
Native errors lack domain context. I extend the Error class to create application-specific exceptions. This pattern improves debugging and error handling precision:
class PaymentError extends Error {
constructor(paymentId, userMessage, originalError) {
super(`Payment ${paymentId} failed: ${userMessage}`);
this.name = 'PaymentError';
this.paymentId = paymentId;
this.originalError = originalError;
this.timestamp = new Date().toISOString();
this.severity = originalError instanceof NetworkError ? 'warning' : 'critical';
}
}
// Usage
try {
processPayment(order);
} catch (err) {
if (err instanceof InvalidCardError) {
showCardErrorForm();
} else {
throw new PaymentError(order.id, 'Payment processing failed', err);
}
}
Custom errors create semantic boundaries between different failure types. I include timestamps and severity levels to help our monitoring systems prioritize alerts.
Error Boundary Components
React applications need component-level containment. I use error boundaries like circuit breakers that isolate failures:
class ComponentErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
componentDidCatch(error, info) {
trackComponentError({
componentStack: info.componentStack,
errorMessage: error.message
});
}
render() {
if (this.state.error) {
return (
<div className="component-fallback">
<h3>Component unavailable</h3>
<button onClick={this.props.onRetry}>Retry</button>
<ErrorDetails error={this.state.error} />
</div>
);
}
return this.props.children;
}
}
// Usage
<ComponentErrorBoundary onRetry={fetchData}>
<UserDashboard />
</ComponentErrorBoundary>
This approach saved my team during a third-party widget failure. While the dashboard component crashed, the rest of the application remained fully functional.
Operational vs Programmer Errors
I classify errors as either operational (expected failures) or programmer (unexpected bugs). This distinction determines handling strategies:
// Operational error - expected and handled
function calculateDiscount(total) {
if (total < 0) {
throw new BusinessRuleError('Total cannot be negative');
}
// Calculation logic
}
// Programmer error - unexpected and fatal
function initializeApp() {
if (!environment.API_BASE_URL) {
throw new ConfigurationError('Missing API base URL');
}
}
// Usage
try {
applyDiscount(cartTotal);
} catch (err) {
if (err instanceof BusinessRuleError) {
showNotification(err.message);
} else {
logCriticalError(err);
shutdownApplication();
}
}
Operational errors represent known failure scenarios like invalid user input. Programmer errors indicate code defects. I fail fast for programmer errors to prevent data corruption.
Building a Resilient Mindset
Robust error handling requires architectural commitment. I combine these patterns throughout my applications:
- Try-catch for synchronous operations
- Promise chains for asynchronous workflows
- Error boundaries for UI components
- Custom errors for domain context
- Global handlers as safety nets
- Operational/programmer error classification
The patterns interconnect like a safety system. Local error handling contains issues at the source. Custom errors preserve context as failures propagate. Global handlers catch what slips through. This layered approach has helped me transform fragile applications into resilient systems.
Measure your error handling effectiveness by tracking two metrics: mean time between failures and mean time to recovery. Good error management improves both. Remember that every error caught is an opportunity to improve user experience. When we handle failures gracefully, we build trust with our users - they know our application won’t abandon them when problems occur.