Handling Async Errors in Express 4.x Without Repeating Try/Catch
Express 4.x does not forward async route rejections to error middleware automatically. Use a wrapper and centralized JSON middleware to return consistent API errors instead of HTML stack traces.
11 Oct 2025, 15:15 UTC

An Async Route That Fails Silently
You add an async route handler to Express, await a database call, and throw when the record is missing. In Express 4.x, that rejection does not automatically reach your error middleware. The promise rejection either crashes the Node process or produces the default HTML error page, leaving your API clients parsing markup instead of JSON. This inconsistency makes production debugging harder and forces you to wrap every async route in try/catch just to stay safe.
Thesis: Centralize Async Errors With a Wrapper and JSON Middleware
Express 5.x can return promises from route handlers and forward rejections to error middleware automatically. If you are on Express 4.x, you need a small wrapper that converts rejected promises into calls to next(err). Combined with a single error middleware placed after your routes, you get consistent JSON error responses without scattering try/catch blocks across your controllers.
Express Version Changes the Requirement
Express 5.x treats a returned promise as settled when the handler finishes. If the promise rejects, Express passes the error to the next error-handling middleware. Express 4.x does not do this; an unhandled rejection inside an async route bypasses error middleware entirely. Before choosing an approach, check your package.json: "express": "^4.x.x" means you need the wrapper pattern described below. "express": "^5.x.x" means native support is available, though you still want centralized middleware for response shaping.
Centralized JSON Error Middleware
Create an error class that carries an HTTP status code, then a middleware function with the four-argument signature (err, req, res, next). Express recognizes this signature as error handling and skips normal middleware when an error is passed to next().
// middleware/error.js
class AppError extends Error {
constructor(message, statusCode = 500) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
const errorHandler = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
const response = {
status: err.status,
message: err.message
};
if (process.env.NODE_ENV === 'development') {
response.stack = err.stack;
}
res.status(err.statusCode).json(response);
};
module.exports = { AppError, errorHandler };
The four-argument signature is intentional. Express ignores error middleware unless all four parameters are declared, even if you do not use req or next inside the function.
Worked Example: Wrap the Async Route
Install the wrapper package in your project directory. No special permissions are required unless your node_modules directory is owned by another user.
npm install express express-async-handler
Then wrap the route handler so rejected promises are forwarded to next(err):
// routes/users.js
const express = require('express');
const asyncHandler = require('express-async-handler');
const { AppError } = require('../middleware/error');
const router = express.Router();
router.get('/:id', asyncHandler(async (req, res) => {
const user = await findUserById(req.params.id);
if (!user) {
throw new AppError('User not found', 404);
}
res.json(user);
}));
Place the error middleware after all route definitions in your application file:
// app.js
const express = require('express');
const { errorHandler } = require('./middleware/error');
const usersRouter = require('./routes/users');
const app = express();
app.use(express.json());
app.use('/api/users', usersRouter);
app.use(errorHandler);
app.listen(3000, () => {
console.log('Listening on port 3000');
});
Run the application locally:
node app.js
Then request a missing user:
curl -i http://localhost:3000/api/users/999
Expected check: the response should have a JSON body such as {"status":"error","message":"User not found"} with HTTP status 404, not an HTML stack trace. If you see HTML or the connection fails, confirm that errorHandler is mounted after the routes and that the route uses asyncHandler.
Trade-Offs and Limitations
- express-async-handler makes the async boundary explicit in every route, which is clear but adds a wrapper around each handler.
- express-async-errors patches Express internals so rejected promises reach error middleware without wrapping each route. It reduces visible boilerplate but changes framework behavior globally, which can surprise developers expecting standard Express 4.x semantics.
- Neither approach catches errors outside the request cycle. Unhandled rejections in background jobs or top-level module code can still crash the process, especially on Node.js versions before 15.0.0.
Verify the Result
- Start the server with
node app.js. - Call a route that throws an AppError, such as a missing resource.
- Confirm the response Content-Type is application/json and the status code matches the error you threw.
- Call a route that throws an unclassified Error to confirm it falls back to 500.
If the response is JSON and the status code is correct, your centralized handler is catching async errors as intended.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.