I joined a startup two years ago. The codebase had try-catch blocks wrapping every single function: routes, services, models, utils. Everything. The team called it “safe coding.”

It was not safe. Within three months, we had three production incidents where errors vanished silently. No logs, no alerts, no way to know something broke until a customer complained. The try-catch blocks did not protect us. They hid the problems from us.
Wrapping everything in try-catch is not defensive programming. It is fear-based programming. Senior developers do not reach for try-catch first. They reach for it last, only when it is the only right tool for the job.
Why Junior Devs Default to Try-Catch
It makes sense on the surface. Something might fail? Wrap it. Linter complains? Add a catch block. The tutorial shows a try-catch. Copy it.
Try-catch is a reactive tool, not a preventive one. Every time you wrap code in try-catch, you are saying: “I do not know what will go wrong here, so I will just catch whatever happens.”
Senior devs ask a different question first: can I prevent this from going wrong in the first place?
Also read Node.js Error Handling: Strategies for Production Applications for the wider picture on classification, global handlers and monitoring.
Pattern 1: Validate First, Catch Never
The most common reason junior devs add try-catch is to handle invalid input. A null value comes in, something breaks, and the catch block saves the day.
// ❌ WRONG - Catching what validation should have prevented
app.post('/users', (req, res) => {
try {
const user = userService.create(req.body);
res.status(200).json(user);
} catch (error) {
if (error instanceof TypeError || error.message.includes('null')) {
res.status(400).json({ error: 'Bad request' });
} else {
res.status(500).json({ error: 'Server error' });
}
}
});
What Senior Devs Do Instead
Validate at the door with a schema library like Joi or Zod. Nothing bad gets inside.
// ✅ CORRECT - Validate at the door
const Joi = require('joi');
const userSchema = Joi.object({
name: Joi.string().required().messages({ 'any.required': 'Name is required' }),
email: Joi.string().email().required().messages({ 'string.email': 'Must be a valid email', 'any.required': 'Email is required' }),
age: Joi.number().min(18).required().messages({ 'number.min': 'Must be at least 18', 'any.required': 'Age is required' }),
});
app.post('/users', (req, res, next) => {
const { error } = userSchema.validate(req.body);
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
const user = userService.create(req.body);
res.status(201).json(user);
});
Senior devs never let invalid input reach the point where it breaks.
The Rule: if you are catching an exception caused by bad input, you have a validation problem, not an exception handling problem.
Also read Input Validation and Sanitization: Joi vs Zod to pick the right schema library for your project.
Pattern 2: A Custom Error Hierarchy
Junior devs catch Error. Senior devs build a hierarchy that makes every failure self-explanatory.
// ❌ WRONG - Generic errors tell you nothing
try {
orderService.process(order);
} catch (error) {
console.error(`Something failed: ${error.message}`);
res.status(500).json({ error: 'Internal server error' });
}
What Senior Devs Do Instead
// ✅ CORRECT - A structured error hierarchy
class AppError extends Error {
constructor(message, statusCode, errorCode) {
super(message);
this.statusCode = statusCode;
this.errorCode = errorCode;
}
}
// Specific errors speak for themselves
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} not found with id: ${id}`, 404, 'NOT_FOUND');
}
}
class BusinessRuleViolatedError extends AppError {
constructor(rule) {
super(`Business rule violated: ${rule}`, 409, 'BUSINESS_RULE_VIOLATED');
}
}
// Usage - no try-catch needed in the service
function processOrder(request) {
const order = orderModel.findById(request.orderId);
if (!order) {
throw new NotFoundError('Order', request.orderId);
}
if (order.isAlreadyProcessed) {
throw new BusinessRuleViolatedError('Order already processed');
}
return orderModel.save(order);
}
When NotFoundError gets thrown, you already know what failed, why it failed, and what HTTP status to return. Zero guesswork.
The Rule: if you are catching a generic error and then trying to figure out what actually went wrong, your errors are not specific enough.
Pattern 3: Handle Errors in One Place
Junior devs put try-catch in every route handler. Senior devs handle all errors in a single, centralized middleware.
// ❌ WRONG - Copy-pasting the same catch logic across 15 routes
app.get('/orders/:id', (req, res) => {
try {
const order = orderService.findById(req.params.id);
res.json(order);
} catch (error) {
if (error instanceof NotFoundError) {
res.status(404).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
What Senior Devs Do Instead
// ✅ CORRECT - One handler. All routes. Zero duplication.
// Error-handling middleware (must be last in the app.use chain)
app.use((err, req, res, next) => {
if (err instanceof NotFoundError) {
res.status(err.statusCode).json({ code: err.errorCode, message: err.message });
} else if (err instanceof BusinessRuleViolatedError) {
res.status(err.statusCode).json({ code: err.errorCode, message: err.message });
} else if (err.name === 'ValidationError') { // e.g., from Joi
res.status(400).json({ code: 'VALIDATION_FAILED', message: err.details.map(d => d.message).join(', ') });
} else {
console.error('Unexpected error:', err);
res.status(500).json({ code: 'INTERNAL_ERROR', message: 'Something went wrong' });
}
});
// Now your routes look like THIS
app.get('/orders/:id', (req, res, next) => {
const order = orderService.findById(req.params.id); // Errors propagate to middleware
res.json(order);
});
Your routes become thin and readable. All the error logic lives in one place.
The Rule: if you are writing the same catch block in more than one route, you need error-handling middleware.
Also read 7 Powerful Nodejs Middleware Patterns for Cleaner Expressjs Apps for more on structuring the middleware chain.
Pattern 4: Result Objects for Expected Failures
Not every failure is exceptional. “User not found” is not an error. It is a normal outcome. Using errors for normal outcomes is like using an ambulance to get to work.
// ❌ WRONG - Throwing errors for expected scenarios
function findUser(id) {
const user = userModel.findById(id);
if (!user) {
throw new NotFoundError('User', id);
}
return user;
}
// Now every caller needs to handle this error
What Senior Devs Do Instead
// ✅ CORRECT - Result objects communicate success and failure explicitly
class Result {
constructor(value, error, success) {
this.value = value;
this.error = error;
this.success = success;
}
static ok(value) {
return new Result(value, null, true);
}
static failure(error) {
return new Result(null, error, false);
}
isSuccess() {
return this.success;
}
getValue() {
return this.value;
}
getError() {
return this.error;
}
}
// Usage - no errors, no guessing
function findUser(id) {
const user = userModel.findById(id);
return user ? Result.ok(user) : Result.failure(`User not found with id: ${id}`);
}
// Caller knows exactly what happened
const result = userService.findUser(123);
if (result.isSuccess()) {
res.json(result.getValue());
} else {
res.status(404).json({ error: result.getError() });
}
The Rule: if a scenario is expected, not a bug and not a system failure, do not throw an error. Return a Result.
The Mental Model
Senior developers think about errors in two categories:
| Category | What it means | Examples | How to handle |
|---|---|---|---|
| Exceptional | Should not happen under normal conditions | DB connection drops, third-party API times out, out of memory | try-catch, structured logging, alerts |
| Expected | Part of normal business logic | User does not exist, order already shipped, payment declined | Validation, Result objects, clean control flow |
Once you start categorizing failures this way, you will naturally write fewer try-catch blocks. Not because you are ignoring errors, but because you are handling them better.
Summary
- Validate at the boundary - Joi or Zod at the door beats a catch block inside
- Build an error hierarchy -
NotFoundErrortells you more thanErrorever will - Centralize handling - One Express error middleware, not fifteen catch blocks
- Return Results for expected failures - Reserve exceptions for the exceptional
- Classify before you catch - Exceptional vs expected decides the tool
Related Articles
- Node.js Error Handling: Strategies for Production Applications - Error classification, global handlers and monitoring
- Node.js Error Handling Patterns for Production - Sync, async and uncaught error patterns
- Input Validation and Sanitization: Joi vs Zod - Choosing a schema validation library
- 7 Powerful Nodejs Middleware Patterns for Cleaner Expressjs Apps - Structuring the middleware chain
- The Pitfalls of Using Async/Await Inside forEach() Loops - Where async errors quietly disappear
Final Thoughts
Do not refactor everything at once. Start with one change.
Find a single try-catch in your codebase that is catching bad input and replace it with schema validation. Pick one generic catch (error) and replace it with a specific custom error. If catch blocks repeat across routes, build the error-handling middleware. Find one place where you throw for an expected scenario and return a Result instead.
Each change makes your code more honest about what is actually happening. That honesty is what “safe code” really means.
✨ Thank you for reading and I hope you find it helpful. I sincerely request for your feedback in the comment’s section.