NodeJSError-Handling Middleware

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

JS
// 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 have exactly four parameters — even if you don't use `next`
Express decides a function is error-handling middleware by counting its parameters: it must declare **all four** `(err, req, res, next)`. Omit `next` and write `(err, req, res)` and Express treats it as *regular* middleware with a weird first argument — errors will never reach it. Keep `next` in the signature even when unused (prefix it `_next` if your linter complains).
It must come LAST

Order is non-negotiable

JS
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 })
})
Registered too early, it catches nothing
Error middleware only catches errors from middleware registered **before** it. Put it last — after every route and after the 404 catch-all. A common mistake is defining it near the top "to be safe"; there, the routes that actually throw run *after* it, so their errors sail past. Last position, every time.
How errors reach the handler

JS
// 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)
})
`next(err)` always works; auto-forwarding is Express 5
The portable way to trigger error handling is `next(err)` — works in every version. **Express 5** additionally forwards thrown sync errors and rejected promises from `async` handlers automatically; **Express 4** does not, so there you must `try/catch` and call `next(err)` (or use an [asyncHandler wrapper](/nodejs/custom-middleware)). When in doubt, call `next(err)` explicitly.
A production-grade error handler

middleware/errorHandler.js

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 }),
    },
  })
}
Never send stack traces or internal messages to clients in production
A stack trace reveals file paths, dependency versions, and logic — a gift to attackers. For `5xx` errors, send a generic message ("Internal Server Error") and log the real details server-side; only expose `err.message`/`stack` when `NODE_ENV !== 'production'`. Likewise, don't forward raw database or third-party error text to the client; map it to a safe, generic response.
Check `res.headersSent` and delegate
If an error happens *after* the response has started streaming, you can't change the status or send JSON — the headers are already on the wire. Guard with `if (res.headersSent) return next(err)`, which hands off to Express's built-in handler to close the connection. Trying to `res.status().json()` at that point throws "Cannot set headers after they are sent".
Custom error classes

JS
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)
})
Typed errors carry their own status — handlers stay clean
Subclassing `Error` with a `status` (and a stable `code`) lets you `throw new NotFoundError()` from deep in your logic and have the central handler translate it to the right HTTP response. Route handlers stop repeating `res.status(404)...` and instead express intent. Keep `code` strings stable — clients may branch on them.
Multiple error handlers

JS
// 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

express.json() bad body

400

err.type === "entity.parse.failed"

Payload over limit

413

err.type === "entity.too.large"

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

Next
A closer look at parsing the request body across content types: [Parsing Request Bodies](/nodejs/body-parsing).