Este artículo está publicado en inglés.
Removing try/catch Boilerplate from Express Routes with asyncHandler
Learn how error middleware, an asyncHandler wrapper and a custom AppError class move Express error responses out of every route and into one consistent place.
Most Express APIs begin with a try/catch in each async route that turns failures into a JSON response. As the API grows, that block gets copied into every handler. Here you will move error responses into one middleware, drop the repeated try/catch with a small wrapper, and signal status codes with a custom error class. For process-level failures, see our guide to production-grade error handling in Node.js.
Why per-route try/catch does not scale
A first version usually wraps the logic and sends a 500 on failure.
app.get("/users", async (req, res) => {
try {
const users = await getUsers();
res.json(users);
} catch (err) {
res.status(500).json({ message: err.message });
}
});
Nothing is wrong here until the shape repeats across dozens of routes.
app.get("/users", async (req, res) => {
try {
// ...
} catch (err) {
res.status(500).json({ message: err.message });
}
});
app.get("/products", async (req, res) => {
try {
// ...
} catch (err) {
res.status(500).json({ message: err.message });
}
});
app.post("/orders", async (req, res) => {
try {
// ...
} catch (err) {
res.status(500).json({ message: err.message });
}
});
Each handler now does two jobs: business logic and deciding how errors look on the wire. Changing the format means editing every route.
Error-handling middleware in Express
Express recognizes error middleware by its arity: a function with four parameters, where the error comes first.
(err, req, res, next)
A minimal version logs and returns a generic 500. Register it after your routes, because Express runs middleware in order. Keep all four parameters even if next is unused, or Express treats it as ordinary middleware.
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({
message: "Internal Server Error"
});
});
Forwarding errors with next(err)
Instead of building the response inside catch, the route hands the error to Express.
app.get("/users", async (req, res, next) => {
try {
const users = await getUsers();
res.json(users);
} catch (err) {
next(err);
}
});
next(err) makes Express skip regular middleware and jump to the error handler:
Request
↓
Route
↓
Business logic
↓
Error?
↓
next(err)
↓
Centralized error handler
↓
HTTP Response
Removing the remaining try/catch
Every route still carries a try/catch. asyncHandler wraps a route function, runs it through Promise.resolve, and sends any rejection to next.
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
The route shrinks to its actual logic:
app.get(
"/users",
asyncHandler(async (req, res) => {
const users = await getUsers();
res.json(users);
})
);
When getUsers() rejects, the wrapper calls:
next(err);
On Express 5, async rejections are forwarded to next automatically, so the wrapper matters mainly on Express 4.
Signaling status codes with AppError
Errors become more useful when they carry an HTTP status:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
}
}
Routes throw meaningful errors instead of formatting responses:
app.get(
"/users/:id",
asyncHandler(async (req, res) => {
const user = await getUser(req.params.id);
if (!user) {
throw new AppError("User not found", 404);
}
res.json(user);
})
);
The handler reads statusCode, falling back to 500:
app.use((err, req, res, next) => {
console.error(err);
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
success: false,
message: err.message || "Internal Server Error"
});
});
A missing user now yields a 404 with:
{
"success": false,
"message": "User not found"
}
Caution: echoing err.message for every error can leak internals such as database messages. Return it only for AppError instances and a generic text otherwise. For a standard payload shape, see our overview of RFC 9457 problem details.
A complete project layout
A simple layout:
src/
├── controllers/
│ └── user.controller.js
├── middleware/
│ ├── asyncHandler.js
│ └── errorHandler.js
├── errors/
│ └── AppError.js
├── routes/
│ └── user.routes.js
└── app.js
The wrapper module
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
module.exports = asyncHandler;
The error class module
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
}
}
module.exports = AppError;
The error handler module
const errorHandler = (err, req, res, next) => {
console.error(err);
res.status(err.statusCode || 500).json({
success: false,
message: err.message || "Internal Server Error"
});
};
module.exports = errorHandler;
A controller using both
Controllers fetch data, throw AppError when needed, and return one success shape.
const asyncHandler = require("../middleware/asyncHandler");
const AppError = require("../errors/AppError");
const getUsers = asyncHandler(async (req, res) => {
const users = await userService.getUsers();
res.json({
success: true,
data: users
});
});
const getUser = asyncHandler(async (req, res) => {
const user = await userService.getUser(req.params.id);
if (!user) {
throw new AppError("User not found", 404);
}
res.json({
success: true,
data: user
});
});
module.exports = {
getUsers,
getUser
};
Wiring it up in app.js
The handler goes last.
const express = require("express");
const userRoutes = require("./routes/user.routes");
const errorHandler = require("./middleware/errorHandler");
const app = express();
app.use(express.json());
app.use("/users", userRoutes);
// Must be after routes
app.use(errorHandler);
module.exports = app;
What the pattern buys you
Less repeated code
Controllers stop repeating:
try {
// ...
} catch (err) {
res.status(500).json(...);
}
One error format
Clients can rely on one structure:
{
"success": false,
"message": "User not found"
}
Focused controllers
A controller's job becomes a straight line:
Get data
↓
Validate
↓
Process
↓
Return result
A single place for observability
Logging, request IDs, and error tracking live in one middleware.
Order matters: register the handler last
It must follow every router it covers:
app.use("/users", userRoutes);
app.use("/products", productRoutes);
// Error handler LAST
app.use(errorHandler);
Registered earlier, it never sees errors from later routes.
Wrapping up
Instead of:
Route
└── try/catch
└── send error response
becomes:
Route
└── business logic
└── throw error
↓
asyncHandler
↓
centralized error handler
↓
consistent HTTP response
The real goal is separating business logic from error presentation:
- Error middleware needs four parameters and must be registered last.
- Use
asyncHandleron Express 4; Express 5 forwards async rejections itself. - Throw
AppErrorwith a status for expected failures, and hide messages of unexpected ones.