Error-Handling Middleware
Error-handling middleware is the one middleware with a different shape: four parameters, (err, req, res, next). Express recognizes that fourth-parameter signature and routes errors to it — any error passed to next(err), or (in Express 5) thrown from an async handler, skips the rest of the pipeline and lands here. A single, well-built error handler is what turns scattered try/catch blocks into one consistent, secure error response.
The four-parameter signature
// Express identifies error middleware ONLY by its 4 parameters:
app.use((err, req, res, next) => {
console.error(err.stack)
res.status(err.status || 500).json({ error: err.message })
})It must come LAST
Order is non-negotiable
const app = express()
app.use(express.json())
app.use('/api/users', usersRouter)
app.use('/api/posts', postsRouter)
// 404 — no route matched (a normal middleware, runs when nothing above responded):
app.use((req, res) => {
res.status(404).json({ error: 'Not Found' })
})
// Error handler — the very last app.use, AFTER all routes and the 404:
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message })
})How errors reach the handler
// 1. Explicitly, by passing an argument to next():
app.get('/a', (req, res, next) => {
next(new Error('boom')) // → error handler
})
// 2. Express 5 only: thrown synchronously or from an async handler:
app.get('/b', async (req, res) => {
const u = await db.find(req.params.id) // rejection auto-forwarded (v5)
if (!u) throw Object.assign(new Error('Not found'), { status: 404 })
res.json(u)
})A production-grade error handler
middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
// If headers already sent, defer to Express's default handler:
if (res.headersSent) return next(err)
const status = err.status || err.statusCode || 500
// Log full detail server-side (never to the client):
if (status >= 500) {
console.error(`[${req.id ?? '-'}] ${req.method} ${req.originalUrl}`, err)
}
res.status(status).json({
error: {
message: status >= 500 ? 'Internal Server Error' : err.message,
code: err.code,
// Only leak stack traces outside production:
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
},
})
}Custom error classes
class HttpError extends Error {
constructor(status, message, code) {
super(message)
this.status = status
this.code = code
}
}
class NotFoundError extends HttpError {
constructor(msg = 'Resource not found') { super(404, msg, 'NOT_FOUND') }
}
// throw a typed error anywhere; the handler reads err.status/err.code:
app.get('/users/:id', async (req, res) => {
const user = await db.find(req.params.id)
if (!user) throw new NotFoundError()
res.json(user)
})Multiple error handlers
// You can chain error handlers; call next(err) to pass to the next one:
app.use((err, req, res, next) => {
if (err.type === 'entity.parse.failed') { // bad JSON from express.json()
return res.status(400).json({ error: 'Malformed JSON' })
}
next(err) // not mine — pass along
})
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message }) // catch-all
})Common error sources to handle
Source | Typical status | Note |
|---|---|---|
| 400 |
|
Payload over | 413 |
|
Validation failure | 422 | From your validator (Zod, Joi) |
Auth missing/invalid | 401 / 403 | Thrown by auth middleware |
Unknown route | 404 | The 404 catch-all (not an error per se) |
Anything unexpected | 500 | Log fully, respond generically |