Express Error Handling
Central Error Middleware Done Right
LinkedIn Hook
"error: something went wrong"
That's the entire error message. No stack trace. No status code. No request ID. No clue which route blew up. Your user is staring at a 500 page, your support inbox is filling up, and your logs are silent.
Most Express developers learn middleware on day one but never learn the fourth argument. They wrap every route in
try/catch, repeat themselves a hundred times, forget one async handler, and watch the entire process crash because a rejected Promise escaped into the void.Express has a built-in solution: a centralized error-handling middleware with the signature
(err, req, res, next). Mounted last, it catches every thrown error, everynext(err)call, and — with a small wrapper — every async rejection. One place to log. One place to format. One place to hide stack traces in production.In Lesson 6.4, I break down the four-argument error middleware, the
asyncHandlerpattern, theAppErrorclass, and the production rules that separate junior backends from senior ones.Read the full lesson -> [link]
#NodeJS #ExpressJS #BackendDevelopment #ErrorHandling #InterviewPrep
What You'll Learn
- Why Express error middleware uses four arguments instead of three
- Where to mount the central error handler (and why order matters)
- How to wrap async route handlers so rejections flow into
next(err) - The difference between
express-async-errorsand a manualasyncHandler - How to build a custom
AppErrorclass with status codes - The 404 catch-all handler — what it is and where it goes
- Operational vs programmer errors in an Express request lifecycle
- Why leaking stack traces in production is a security incident
- How to format consistent JSON error responses
The Hospital Triage Analogy — Why One Central Handler Wins
Imagine a hospital where every doctor handles every emergency themselves. A patient walks in with chest pain — the receptionist tries CPR. Another walks in with a broken leg — the cafeteria worker grabs splints. Every employee scrambles to handle every kind of crisis, often poorly, often duplicated, often forgotten. Some emergencies fall through the cracks because nobody knew it was their job.
Now imagine a hospital with a triage desk. Every emergency, no matter where it starts, gets routed to the same trained team. They classify the problem, assign the right severity, log it in the system, and dispatch the right specialist. One process. One log. One source of truth.
That is exactly what Express's central error middleware does. Instead of every route handler scattering try/catch blocks and sending its own error responses, every error flows to one middleware at the bottom of your app. That middleware decides the status code, formats the JSON, logs the incident, and hides the stack trace in production. Your routes stay focused on the happy path. Your error logic lives in one file.
+---------------------------------------------------------------+
| SCATTERED ERROR HANDLING (The Problem) |
+---------------------------------------------------------------+
| |
| GET /users -> try/catch -> res.status(500).json(...) |
| POST /users -> try/catch -> res.status(500).json(...) |
| GET /posts -> try/catch -> res.status(500).json(...) |
| PUT /posts -> FORGOT try/catch -> process crash! |
| |
| Result: duplicated code, inconsistent shapes, missed errors |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| CENTRAL ERROR MIDDLEWARE (The Solution) |
+---------------------------------------------------------------+
| |
| GET /users -+ |
| POST /users -+--> next(err) --> [ ERROR MIDDLEWARE ] |
| GET /posts -+ - logs |
| PUT /posts -+ - sets status |
| - formats JSON |
| - hides stack in prod |
| |
| Result: one place to change, consistent shape, nothing leaks |
| |
+---------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). Split comparison: LEFT side labeled 'Scattered' shows four route boxes each with red try/catch blocks and inconsistent error shapes (red #ef4444 highlights). RIGHT side labeled 'Central' shows the same routes funneling into a single green error middleware box that outputs a uniform JSON shape (Node green #68a063). Amber (#ffb020) arrows show the flow. White monospace labels."
The Four-Argument Signature — How Express Knows It's an Error Handler
Normal Express middleware takes three arguments: (req, res, next). Error-handling middleware takes four: (err, req, res, next). Express inspects the function's .length property at registration time. If it sees four parameters, it treats the function as an error handler and skips it during normal request flow — only invoking it when an error has been passed via next(err) or thrown synchronously inside a handler.
Basic Error Middleware
// app.js
const express = require('express');
const app = express();
app.use(express.json());
// Normal route — sync error
app.get('/boom', (req, res) => {
// A synchronous throw is caught by Express automatically
throw new Error('Something exploded');
});
// Normal route — manual next(err)
app.get('/forbidden', (req, res, next) => {
// Pass an error to next() to skip remaining middleware
// and jump straight to the error handler
const err = new Error('You shall not pass');
err.status = 403;
return next(err);
});
// Central error handler — MUST be defined LAST
// Note the FOUR arguments — this is how Express identifies it
app.use((err, req, res, next) => {
// Log the full error server-side (never to the client)
console.error('[error]', err);
// Pick a status code, default to 500
const status = err.status || err.statusCode || 500;
// Send a clean JSON body
res.status(status).json({
error: {
message: err.message || 'Internal Server Error',
status,
},
});
});
app.listen(3000, () => console.log('listening on 3000'));
Key rules:
- The error middleware is the last
app.use(...)call in your app. Anything mounted after it will run before it on a normal request, but will never see errors first. - You must keep all four parameters even if you don't use
next. Removingnextmakes Express think it's a regular middleware. - Synchronous
throwis caught automatically. Asynchronous rejections are not — that's what the wrapper pattern below is for.
The asyncHandler Wrapper — Catching Async Rejections
Here's the trap that breaks most Express apps. This route looks fine, but it will silently crash the process:
// BROKEN — async error escapes Express
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id); // throws if DB is down
res.json(user);
});
When findById rejects, the Promise is unhandled. Express never sees the error, so the central handler never runs, the client hangs until timeout, and Node logs UnhandledPromiseRejection (and in newer versions, terminates the process).
There are two fixes. The clean one is a tiny wrapper:
Manual asyncHandler
// utils/asyncHandler.js
// Wrap an async route handler so rejected promises flow into next(err)
const asyncHandler = (fn) => (req, res, next) => {
// Promise.resolve handles both sync returns and async functions
// .catch(next) forwards any rejection to the error middleware
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
// routes/users.js
const express = require('express');
const router = express.Router();
const asyncHandler = require('../utils/asyncHandler');
// Wrap every async handler — rejections now reach the error middleware
router.get(
'/:id',
asyncHandler(async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) {
// Throwing inside an async function is fine — asyncHandler catches it
const err = new Error('User not found');
err.status = 404;
throw err;
}
res.json(user);
})
);
module.exports = router;
That tiny three-line helper is the single most impactful pattern in Express error handling. Wrap it once, use it everywhere, sleep at night.
Alternative: express-async-errors
If you don't want to wrap every handler, the express-async-errors package monkey-patches Express's router so async rejections are forwarded automatically. You import it once at the top of your entry file:
// app.js
require('express-async-errors'); // patch Router to handle async rejections
const express = require('express');
const app = express();
// Now plain async handlers work — no wrapper needed
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user);
});
Tradeoff: the manual wrapper is explicit and dependency-free. The package is invisible but adds a global side effect. Pick one and apply it consistently — never mix.
The AppError Class — Operational Errors with Status Codes
Generic Error instances work, but they don't carry the metadata your error handler needs. A custom error class lets you throw rich, intentional errors that the central handler can format consistently.
// utils/AppError.js
// A custom error class for *operational* errors —
// expected failures we know how to handle (404, 400, 401, etc.)
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
// 4xx -> 'fail' (client problem), 5xx -> 'error' (server problem)
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
// Mark as operational so the error handler can distinguish
// it from programmer bugs (TypeError, ReferenceError, etc.)
this.isOperational = true;
// Strip the constructor frame from the stack trace for cleaner logs
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
// routes/posts.js
const AppError = require('../utils/AppError');
const asyncHandler = require('../utils/asyncHandler');
router.get(
'/:id',
asyncHandler(async (req, res, next) => {
const post = await db.posts.findById(req.params.id);
if (!post) {
// Throw a known, operational error with a status code
throw new AppError('Post not found', 404);
}
if (post.authorId !== req.user.id) {
throw new AppError('You are not allowed to view this post', 403);
}
res.json(post);
})
);
Now the error middleware has everything it needs — message, status code, severity class, and a flag distinguishing expected failures from unexpected bugs.
The 404 Handler — Catching Unmatched Routes
Express does not have a built-in 404. If no route matches, Express falls through to the end of the middleware chain and the response just hangs. You need an explicit catch-all mounted after all your routes but before the error middleware.
// app.js
const AppError = require('./utils/AppError');
// ... all routes mounted above ...
app.use('/api/users', usersRouter);
app.use('/api/posts', postsRouter);
// 404 catch-all — any request that reaches here matched no route
// Use app.use without a path so it runs for every method + URL
app.use((req, res, next) => {
// Forward a 404 AppError to the central error handler
next(new AppError(`Route not found: ${req.method} ${req.originalUrl}`, 404));
});
// Central error handler — mounted LAST
app.use(errorHandler);
The order is fixed and non-negotiable:
1. Body parsers, CORS, logging
2. All routes (users, posts, auth, etc.)
3. 404 handler
4. Central error handler (4-argument)
Anything out of order silently breaks. A 404 handler placed before routes will catch every request. An error handler placed before the 404 handler will never see 404s.
Production vs Development — Don't Leak Stack Traces
Stack traces are gold during development. They're a security incident in production. They can reveal file paths, library versions, internal function names, ORM query fragments, and even environment variables embedded in error messages. Attackers love them.
The central error handler should branch on NODE_ENV:
// middleware/errorHandler.js
// Central error handler — formats responses based on environment
const errorHandler = (err, req, res, next) => {
// Default to 500 if no status was attached
const statusCode = err.statusCode || err.status || 500;
const isOperational = err.isOperational === true;
// Always log server-side with full detail (use a real logger in prod)
console.error('[error]', {
method: req.method,
url: req.originalUrl,
status: statusCode,
message: err.message,
stack: err.stack,
});
if (process.env.NODE_ENV === 'development') {
// Development: send everything for fast debugging
return res.status(statusCode).json({
status: err.status || 'error',
message: err.message,
stack: err.stack,
error: err,
});
}
// Production: only leak operational errors verbatim
if (isOperational) {
return res.status(statusCode).json({
status: err.status,
message: err.message,
});
}
// Programmer errors / unknown bugs: hide the details
return res.status(500).json({
status: 'error',
message: 'Something went wrong',
});
};
module.exports = errorHandler;
Why this matters: A TypeError: Cannot read properties of undefined (reading 'password') reveals that you have a password field on some object you forgot to populate. A leaked stack trace pointing to node_modules/jsonwebtoken/sign.js:42 tells an attacker exactly which JWT library version you use — and which CVEs to try. In production, the user gets a polite generic message; the operator gets the full detail in the log aggregator.
Operational vs Programmer Errors
This is the conceptual split that drives everything above:
+---------------------------------------------------------------+
| OPERATIONAL ERRORS | PROGRAMMER ERRORS |
+----------------------------------+----------------------------+
| Expected failure modes | Bugs in YOUR code |
| Recoverable | NOT recoverable |
| Send to client with real msg | Hide from client |
| | |
| Examples: | Examples: |
| - 400 invalid input | - TypeError: undefined |
| - 401 unauthenticated | - ReferenceError |
| - 403 forbidden | - calling a non-function |
| - 404 not found | - mis-typed property name |
| - 409 conflict | - missing await |
| - 503 third-party API down | - infinite recursion |
| | |
| Action: respond cleanly | Action: log + alert + 500 |
+----------------------------------+----------------------------+
The isOperational flag on AppError is how the central handler tells them apart. Operational errors flow through with their real message. Programmer errors get a generic 500 response so attackers learn nothing.
For truly fatal programmer errors (an uncaughtException or unhandledRejection outside any request), the modern advice is to let the process crash and rely on a process manager (PM2, systemd, Kubernetes) to restart it. Trying to keep a crashed process alive corrupts state.
// app.js — top of file
process.on('uncaughtException', (err) => {
console.error('[fatal] uncaughtException:', err);
// Don't try to recover — exit cleanly so the supervisor restarts
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('[fatal] unhandledRejection:', reason);
process.exit(1);
});
Common Mistakes
1. Forgetting the fourth argument.
app.use((err, req, res) => { ... }) looks fine but Express sees three parameters and treats it as a normal middleware. It will never run on errors. The fourth next parameter is required for Express to recognize the function as an error handler — keep it even if you don't call it.
2. Mounting the error handler too early.
Express middleware runs in registration order. If you put app.use(errorHandler) before your routes, errors thrown by those routes will fall through past it because there's nothing after it to catch them. The error handler must be the last app.use.
3. Not wrapping async handlers.
A plain async route that throws will produce an unhandled promise rejection that Express never sees. The central handler stays silent, the client hangs, and modern Node terminates the process. Either wrap every async handler in asyncHandler or import express-async-errors once at the top.
4. Sending the response twice.
Calling res.json(...) and then next(err) triggers an ERR_HTTP_HEADERS_SENT error inside the error middleware itself. Always return after responding, and never call next(err) once you've already started sending a response.
5. Leaking stack traces in production.
Returning err.stack in the JSON body is a security leak. Branch on NODE_ENV so stack traces only appear in development. Production responses should contain a status code, a generic message for programmer errors, and a real message only for operational errors marked isOperational: true.
6. Treating every error as 500.
A user typing a wrong ID is a 404, not a 500. A missing field is a 400. An expired token is a 401. Always attach a statusCode to your errors (via AppError) so the central handler can pick the right HTTP status — clients depend on these codes for retry logic and error UIs.
Interview Questions
1. "Why does Express error-handling middleware require four arguments?"
Express identifies error-handling middleware by inspecting the function's .length property at registration time. If the function has four parameters — (err, req, res, next) — Express treats it as an error handler and skips it during normal request flow. It only invokes it when an error has been forwarded via next(err) or thrown synchronously inside another handler. If you remove next and write (err, req, res), the function has three parameters and Express treats it as a normal middleware that will never receive errors. The fourth argument is structural, not optional, even if you never call it.
2. "Why doesn't Express catch errors from async route handlers, and how do you fix it?"
Express was built before async/await existed. Its router catches synchronous throws but does not await the return value of route handlers, so a Promise rejected inside an async function escapes Express entirely and becomes an unhandled rejection. The fix is to wrap every async handler so rejections are forwarded to next. The minimal wrapper is const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next). Apply it to every async route. Alternatively, import express-async-errors once at the top of your entry file — it monkey-patches the router to await handlers and forward rejections automatically.
3. "What is the difference between operational and programmer errors, and how should an Express app handle each?"
Operational errors are expected, recoverable failures: invalid input, missing resources, expired tokens, third-party APIs being down. They have known causes and known responses, so the API should return the real message and an appropriate 4xx status. Programmer errors are bugs in your own code: TypeErrors, ReferenceErrors, mis-typed property names, missing awaits. They are not recoverable because you don't know what state was corrupted. The central error handler should respond to programmer errors with a generic 500 message and hide the actual details from the client (to avoid leaking internals to attackers), while logging the full stack trace server-side. A common pattern is to mark expected errors with an isOperational: true flag on a custom AppError class.
4. "How do you implement a 404 handler in Express, and where does it go in the middleware chain?"
Express has no built-in 404. You add a catch-all middleware after all your routes but before the central error handler. Because Express runs middleware in registration order, any request that didn't match an earlier route will fall through to this handler. Inside it, you forward a 404 error to the error middleware: app.use((req, res, next) => next(new AppError('Route not found', 404))). The fixed order is: parsers and global middleware, then routes, then the 404 handler, then the central error handler. Putting the 404 handler before routes catches every request. Putting it after the error handler means it never runs.
5. "Why is it dangerous to send err.stack in production responses, and what should you do instead?"
Stack traces reveal internal file paths, library versions, function names, and sometimes embedded data like config values or query fragments. An attacker who sees node_modules/jsonwebtoken/sign.js:42 learns exactly which JWT library and version you use, which CVEs apply, and what your project layout looks like. The central error handler should branch on process.env.NODE_ENV. In development, return the full stack and the error object for fast debugging. In production, return only the message for operational errors and a generic "Something went wrong" for programmer errors. The full stack should still be logged server-side via your logger (Winston, Pino, etc.) so operators can debug, but it must never leave the server in an HTTP response.
Quick Reference — Express Error Handling Cheat Sheet
+---------------------------------------------------------------+
| EXPRESS ERROR HANDLING CHEAT SHEET |
+---------------------------------------------------------------+
| |
| CENTRAL HANDLER SIGNATURE: |
| app.use((err, req, res, next) => { ... }) |
| - 4 args = Express treats it as an error handler |
| - Mount LAST |
| |
| ASYNC WRAPPER: |
| const asyncHandler = (fn) => (req, res, next) => |
| Promise.resolve(fn(req, res, next)).catch(next); |
| |
| CUSTOM ERROR CLASS: |
| class AppError extends Error { |
| constructor(msg, status) { |
| super(msg); |
| this.statusCode = status; |
| this.isOperational = true; |
| } |
| } |
| |
| 404 HANDLER: |
| app.use((req, res, next) => |
| next(new AppError('Not found', 404)) |
| ); |
| |
| PRODUCTION RULE: |
| if (NODE_ENV === 'production' && !err.isOperational) { |
| return res.status(500).json({ |
| message: 'Something went wrong' |
| }); |
| } |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| MIDDLEWARE ORDER (NON-NEGOTIABLE) |
+---------------------------------------------------------------+
| |
| 1. Body parsers, CORS, helmet, logging |
| 2. Routes (users, posts, auth, etc.) |
| 3. 404 catch-all |
| 4. Central error handler (4-argument) |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| KEY RULES |
+---------------------------------------------------------------+
| |
| 1. Error middleware needs 4 args, mounted LAST |
| 2. Wrap every async handler (or use express-async-errors) |
| 3. Throw AppError with a real status code, not generic Error |
| 4. Mark expected errors isOperational = true |
| 5. Never send err.stack in production responses |
| 6. Always log full error server-side |
| 7. Let uncaughtException crash the process — supervisor |
| restarts |
| |
+---------------------------------------------------------------+
| Concern | Wrong Approach | Right Approach |
|---|---|---|
| Async errors | Plain async handler | asyncHandler wrapper |
| Status codes | res.status(500) everywhere | AppError with real code |
| Response shape | Different per route | Single central handler |
| Stack traces in prod | Sent to client | Logged server-side only |
| Unknown 500s | Show full message | Generic "Something went wrong" |
| 404s | None — hangs forever | Catch-all before error handler |
uncaughtException | Try to recover | Log + process.exit(1) |
Prev: Lesson 6.3 -- Routing Organization Next: Lesson 6.5 -- Request Validation and Security
This is Lesson 6.4 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.