Join JS Mastery Pro to apply what you learned today through real-world builds, weekly challenges, and a community of developers working toward the same goal.
If you’ve written even a few Express controllers, you’ve likely fallen into the Try-Catch Trap. It starts small, but before you know it, your beautiful logic is buried under a mountain of repetitive error handling.
If you want to stop writing messy code and learn industry-standard patterns for scalable systems, join the waitlist for the Ultimate Backend Course.
Look at this standard controller. It works, but it’s a maintenance nightmare:
app.get('/users/:id', async (req, res) => {
try {
const user = await getUserById(req.params.id);
if (!user) {
// Repetitive manual status codes
return res.status(404).json({ success: false, message: 'User not found' });
}
res.status(200).json({ success: true, data: user });
} catch (error) {
// You have to write this EVERY. SINGLE. TIME.
res.status(500).json({ success: false, message: 'Internal Server Error' });
}
});The Problem: You are hard-coding status codes and JSON structures everywhere. If your lead developer says, "We need to change our error format from message to error_details," you now have to edit 50 different files.
Not cool, right? Luckily, Express has a secret weapon that can save you from this chaos.
Express has a built-in "Safety Net." It’s a special type of middleware that only runs when an error occurs. By adding this at the end of your middleware stack (usually in app.js or server.ts), you create a Central Hub for all disasters.
// The Global Error Handler
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
success: false,
message: err.message || 'Internal Server Error',
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
});
});By adding this, now, instead of sending a response inside your catch block, you can just "hand off" the error using next(err).
app.get('/users/:id', async (req, res, next) => {
try {
const user = await getUserById(req.params.id);
if (!user) {
return res.status(404).json({ success: false, message: 'User not found' });
}
res.status(200).json({ success: true, data: user });
} catch (error) {
next(error);
}
});Now, any error thrown inside the controller catch block gets sent directly to the global middleware, which handles it consistently by reading err.message.
But there’s still a problem. Returning a response manually for cases like this:
if (!user) {
return res.status(404).json({ success: false, message: 'User not found' });
}It is still repetitive and scattered across controllers. To truly level up, we need a centralized, consistent way to handle all types of errors, not just those from catch blocks.
To handle errors like a pro, we need a structured, reusable error class. This keeps your error responses consistent and makes it easy to throw errors anywhere in your app. For that, we will create a utility class called AppError and take advantage by extending the built-in JavaScript Error class.
Sounds fancy? Don’t worry, it’s simple:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // This marks the error as "expected" (like a 404)
Error.captureStackTrace(this, this.constructor);
}
}
export default AppError;Here’s what’s happening:
Why extend Error at all? Because Express treats instances of Error (or anything inheriting from it) specially. Our global error middleware can now catch all errors, whether built-in or custom, and respond consistently.
Now, instead of doing this in your controller:
if (!user) {
return res.status(404).json({ success: false, message: 'User not found' });
}You can just throw an AppError:
if (!user) {
throw new AppError('User not found', 404);
}That’s it. But there’s still one step left: making sure our global error middleware handles this new AppError properly. Luckily, it’s simple, we just check if the error is operational and send a consistent response:
// Global Error Handler (updated)
app.use((err, req, res, next) => {
// If this is an AppError, use its statusCode; otherwise default to 500
const statusCode = err.statusCode || 500;
const message = err.isOperational ? err.message : 'Internal Server Error';
res.status(statusCode).json({
success: false,
message,
// Only show stack trace in development
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
});
});Now, any AppError thrown anywhere in your app, whether it’s a missing user, invalid input, or anything else, automatically flows to this middleware. You don’t need to manually send a response in the controller, and your response format stays consistent across the entire API.
Your controllers can now stay clean:
app.get('/users/:id', async (req, res, next) => {
try {
const user = await getUserById(req.params.id);
if (!user) throw new AppError('User not found', 404);
res.status(200).json({ success: true, data: user });
} catch (error) {
next(error);
}
});This is the point where beginners start feeling like pros: throw, forget, and let the global middleware handle the rest.
So now you make the controller much cleaner than previous, do you want to make it clean more? Why not?
Even with AppError, we still have try-catch blocks everywhere. Let's kill them once and for all with a Higher-Order Function called catchAsync.
// utils/catchAsync.js
const catchAsync = (fn) => {
return (req, res, next) => {
fn(req, res, next).catch(next); // Automatically forwards errors to next()
};
};
export default catchAsync;Now, look at how clean your code becomes. No try-catch, no manual res.status. Just pure logic:
import catchAsync from './utils/catchAsync';
app.get(
'/users/:id',
catchAsync(async (req, res, next) => {
const user = await getUserById(req.params.id);
if (!user) return next(new AppError('User not found', 404));
res.status(200).json({ success: true, data: user });
}),
);Notice how everything is centralized:
By moving error handling out of your routes and into reusable utilities, your code becomes scalable, readable, and a joy to maintain. You’re not just writing controllers anymore, you’re building a solid architecture.
If you want to stop writing messy code and learn industry-standard patterns for scalable systems, join the waitlist for the Ultimate Backend Course.