Error Handling Patterns: Beyond try/catch
11 min read
Error Handling Patterns
Production Node.js apps need a layered error strategy: structured error classes, per-operation try/catch, and global safety nets for anything that slips through. Getting this right prevents silent failures and zombie processes.
Structured Error Hierarchy
js
class AppError extends Error {
constructor(message, code, statusCode = 500) {
super(message);
this.name = "AppError";
this.code = code; // machine-readable, e.g. "NOT_FOUND"
this.statusCode = statusCode;
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message, fields = {}) {
super(message, "VALIDATION_ERROR", 400);
this.name = "ValidationError";
this.fields = fields;
}
}
// Usage
async function getUser(id) {
if (!id) throw new ValidationError("id is required", { id: "missing" });
const user = await db.find(id);
if (!user) throw new AppError("User not found", "NOT_FOUND", 404);
return user;
}Process-Level Safety Nets
js
// Catch unhandled Promise rejections (Node.js 15+ terminates by default)
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection at:", promise, "reason:", reason);
// Log to monitoring (Sentry, Datadog, etc.) then shut down gracefully
process.exit(1);
});
// Catch synchronous exceptions that escaped all try/catch blocks
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err);
// Do NOT continue running — the process state is undefined
process.exit(1);
});
// Graceful shutdown on SIGTERM (sent by Docker, Kubernetes, etc.)
process.on("SIGTERM", async () => {
console.log("SIGTERM received — shutting down gracefully");
await server.close();
process.exit(0);
});Result Pattern (no-throw alternative)
js
// Return { data, error } instead of throwing — avoids hidden control flow
async function safeReadFile(filePath) {
try {
const data = await require("fs/promises").readFile(filePath, "utf8");
return { data, error: null };
} catch (err) {
return { data: null, error: err };
}
}
const { data, error } = await safeReadFile("config.json");
if (error) {
console.error("Could not load config:", error.message);
} else {
console.log("Config loaded:", data);
}The result pattern (inspired by Go and Rust) makes error paths explicit at the call site and is gaining popularity in TypeScript Node.js codebases. Libraries like neverthrow formalise it with typed Result<T, E> objects.
Ready to test yourself?
Practice Node.js— quiz & coding exercises