
Modern Error Handling: Beyond try/catch
Abhay Vachhani
Developer
Every developer knows try/catch, but many use it as a "band-aid" rather than a strategy. Professional backend development requires a robust error handling architecture that prevents crashes, maintains data integrity, and provides clear feedback. In this guide, we'll move beyond the basics of error handling.
1. The Power of Custom Error Classes
Relying on generic Error objects makes it impossible to distinguish between a "User Not Found" error and a "Database Connection" error programmatically. By extending the built-in Error class, you can create a taxonomy of errors for your application.
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // Mark as expected error
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 400);
}
}
throw new ValidationError("Invalid email format");
2. Operational vs. Programmer Errors
This is the most critical distinction in backend stability:
- Operational Errors: Expected failures we can anticipate (e.g., invalid user input, network timeouts, 404s). We handle these gracefully.
- Programmer Errors: Bugs in the code (e.g.,
cannot read property of undefined). These are unpredictable. The safest action for a programmer error is usually to crash the process and let a manager like PM2 restart it, preventing the app from staying in a corrupted state.
3. Rethrowing and Wrapping Errors
Sometimes you catch an error just to log it or add context before sending it up the chain. Always ensure you don't swallow the original stack trace.
try {
await database.save(user);
} catch (err) {
throw new AppError(`Failed to save user: ${err.message}`, 500);
}
4. The Result Pattern: A Functional Alternative
In high-stakes logic, throwing exceptions can make control flow hard to follow. The Result Pattern returns an object indicating success or failure instead of using try/catch for logic flow.
function findUser(id) {
const user = db.get(id);
if (!user) {
return { success: false, error: "Not Found" };
}
return { success: true, data: user };
}
const result = findUser(123);
if (!result.success) {
return handleFailure(result.error);
}
const user = result.data;
5. Global Unhandled Rejection Handling
Never let a Promise rejection go unhandled. In Node.js, these can leave your application in an "unstable" state without crashing it, making it harder to debug.
process.on('unhandledRejection', (err) => {
console.error('UNHANDLED REJECTION! 💥 Shutting down...');
console.error(err.name, err.message);
server.close(() => {
process.exit(1);
});
});
Conclusion
Robust error handling isn't about preventing errors; it's about managing them. By categorizing errors, using custom classes, and preferring the Result pattern for core logic, you build backend systems that are resilient, debuggable, and professional.
FAQs
Why use Error.captureStackTrace?
It prevents the custom error class constructor itself from appearing in the stack trace, making the logs cleaner and focused on where the error actually occurred.
Should I ever "swallow" an error?
Only if it is truly inconsequential (e.g., failing to log a non-critical event) and you have a clear comment explaining why it is safe to ignore.
Is try/catch expensive?
In modern JS engines, the overhead of a try block is minimal. However, creating and throwing an Error object (generating the stack trace) is relatively expensive. Use it for exceptional cases, not normal control flow.