In our last post, we built the ApiError class and the asyncHandler. We now have a standardized way to throw errors.
But if you throw a ball and nobody catches it, it hits the ground.
Right now, if an error happens in your app, Express either crashes or sends a generic HTML stack trace to the user. This is bad for security (leaking info) and bad for the frontend (parsing HTML is impossible).
Today, we build the Global Error Handler—the final safety net that catches everything, scrubs sensitive data, and returns a beautiful, consistent JSON response.
Step 1: Handling "Not Found" Routes
Before we handle crashes, we need to handle "Lost" users. If someone requests a URL that doesn't exist, we shouldn't send HTML. We should send a standard 404 JSON error.
This doesn't need a file. We can add it directly to app.js later.
Step 2: The Central Error Middleware
Unlike the regular middleware we discussed earlier, an error handler takes four arguments: (err, req, res, next). When you pass an error into next(err)—which our asyncHandler does automatically—Express skips all other functions and dives straight into this one.
Crucial Rule: Express only recognizes a function as an Error Handler if it has four arguments:
(err, req, res, next). Even if you don't usenext, you must include it, or Express will treat this as a regular middleware.
Create: src/middlewares/error.middleware.js
import { ApiError } from "../utils/ApiError.js";
const errorHandler = (err, req, res, next) => {
let error = err;
// If the error isn't already an ApiError, wrap it so it's consistent
if (!(error instanceof ApiError)) {
const statusCode = error.statusCode || 500;
const message = error.message || "Internal Server Error";
error = new ApiError(statusCode, message, error?.errors, err.stack);
}
const response = {
...error,
message: error.message,
// Pro-tip: Only show the stack trace in development mode!
...(process.env.NODE_ENV === "development" ? { stack: error.stack } : {}),
};
return res.status(error.statusCode).json(response);
};
export { errorHandler };⚠️ Important: Configure Your Environment
Notice this line in the code above?
...(process.env.NODE_ENV === "development" ? { stack: error.stack } : {}),This is a security feature. It ensures that we only see detailed error logs (Stack Traces) when we are working locally. In a real website (Production), leaking stack traces is a massive security risk because it reveals your file structure to hackers.
To make this work, you need to tell Node.js that you are currently in "Development" mode.
Update: Open your .env file and add this line:
NODE_ENV=development
If you don't add this, process.env.NODE_ENV will be undefined, and your stack traces will be hidden even on your own machine.
Try it yourself: Change the
NODE_ENVvalue in your.envfile to "development". Then, trigger an error by calling an unmatched route or intentionally causing a server error. Observe how the response changes based on theNODE_ENVsetting: in development, you'll see the full stack trace, while in production (or withNODE_ENVundefined), the stack trace will be hidden.
Why this is critical
Without this change:
- Locally:
process.env.NODE_ENVis undefined. The conditionundefined === 'development'fails. You see no stack trace, making debugging impossible. - Security: The post now explicitly explains why we hide the stack trace (to prevent leaking file paths).
Step 3: Wiring the "Drain" into app.js
Think of your middleware stack like a series of pipes. The error handler must be the very last thing you plug in. If you place it above your routes, the errors falling from your controllers will miss the net.
Order is everything here.
- Routes come first.
- 404 Handler comes second (if no route matched).
- Error Handler comes last (to catch everything).

Update: src/app.js
You don't need to rewrite your whole file. Just import the handler and place it at the very bottom:
// ... existing imports
import { errorHandler } from './middlewares/error.middleware.js';
import { ApiError } from './utils/ApiError.js';
// ... your app.use(requestLogger), app.use('/users', userRoutes), etc.
// --- ERROR HANDLING ---
// 1. 404 Handler: If request hits this point, no route matched
app.use((req, res, next) => { next(new ApiError(404, "Page not found")); });
// 2. The Global Error Handler MUST be the last middleware in the stack
app.use(errorHandler);
export default app;Why This Architecture Wins
Because your controllers are wrapped in the asyncHandler we built last time, any throw new ApiError(...) you write—or any unexpected crash—will now be automatically funneled into this central middleware.
The result? Your frontend always receives a clean, predictable JSON object. You've effectively built a safety net that catches everything from database timeouts to simple typos, scrubs sensitive stack traces in production, and keeps your server running smoothly.
With the asyncHandler managing your successful requests and the errorHandler catching the failures, the core "plumbing" of your backend is officially production-ready. You now have a system that is easy to read, effortless to debug, and incredibly resilient.
Now that our foundation is solid, it's time to build an impenetrable shield around it.
Right now, if a client sends a request without an email, or with a password that's only two characters long, our server will try to process it—and likely throw a messy database error. Relying on your database to catch bad data is slow, expensive, and insecure.
In the next post, Trust Nobody: Data Validation with Zod, we’ll learn how to lock down our inputs. We'll build middleware that intercepts bad requests, validates complex schemas before they ever reach your controllers, and returns precise, helpful error messages to the frontend using the very global error handler we just built.
Happy coding!