Error Handling
Operational vs programmer errors. Custom error classes. Async pitfalls in Node.
Error Handling Philosophy
Errors are expected. Every external call can fail: database, cache, third-party API. Design your system assuming failures happen.
Categories of errors:
Operational errors — expected runtime errors. DB connection lost, user not found, validation failed. Handle gracefully, return appropriate status.
Programmer errors — bugs. Null pointer, wrong type, logic error. Don't catch these — let them crash and alert. Fix the code.
Transient errors — temporary failures. Network blip, API timeout. Retry with backoff.
Permanent errors — will always fail. Invalid email format, user not found. No point retrying.
Custom Error Classes
Use typed errors to distinguish cases and set HTTP status codes:
class AppError extends Error {
constructor(message, statusCode = 500, code) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true; // expected error
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404, "NOT_FOUND");
}
}
class ValidationError extends AppError {
constructor(details) {
super("Validation failed", 400, "VALIDATION_ERROR");
this.details = details;
}
}
class ConflictError extends AppError {
constructor(message) { super(message, 409, "CONFLICT"); }
}
// In handler, throw typed errors:
throw new NotFoundError("User");
// Error middleware catches and formats the response.
Global Error Middleware
One error handler catches everything. Services/handlers just throw — they don't format HTTP responses.
// Express error middleware (4 args = error handler)
app.use((err, req, res, next) => {
// Log the error
logger.error({ err, requestId: req.requestId });
// Operational error: send clean response
if (err.isOperational) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details
}
});
}
// Programmer error: don't leak internals
res.status(500).json({
error: { code: "INTERNAL_ERROR", message: "Something went wrong" }
});
// Optionally: process.exit(1) for unrecoverable programmer errors
});
Async Error Pitfalls
In Node.js, unhandled promise rejections are silent errors (they're caught now, but were historically dropped).
// Dangerous: unhandled rejection
app.get("/users", async (req, res) => {
const users = await db.getUsers(); // if this throws, who catches it?
res.json(users);
});
// Safe: wrap in try-catch
app.get("/users", async (req, res, next) => {
try {
const users = await db.getUsers();
res.json(users);
} catch (err) {
next(err); // passes to error middleware
}
});
// Better: use a wrapper utility
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get("/users", asyncHandler(async (req, res) => {
const users = await db.getUsers();
res.json(users);
}));
The Backend from First Principles series is based on what I learnt from Sriniously's YouTube playlist — a thoughtful, framework-agnostic walk through backend engineering. If this material helped you, please go check the original out: youtube.com/@Sriniously. The notes here are my own restatement for revisiting later.