| |

Node.js 34 🟢 Centralized Error Handling Middleware in Express

An error handler is not the place to fix the error. It is the place to translate the error into a response. The route throws, the error propagates, and the centralized handler receives it. The handler decides the status code, the message, and the format. It logs the error for the operators, and it sends the sanitized response to the client. Without the centralized handler, every route repeats the same try/catch and the same res.status(500).json(...). With it, the error handling is one function, and the routes are the logic.

Key point: The Express error-handling middleware has four arguments: (err, req, res, next). Express identifies it by the arity. It must be registered after all the routes and the other middleware. The handler receives the error from next(err), from a thrown exception in a synchronous handler, or from a rejected promise in an async handler (in Express 5, or with a wrapper in Express 4). The handler sets the status, logs the error, and sends the response. A centralized handler is one place to set the error format, the logging, and the cleanup.


Why centralized error handling matters

The duplication problem. Without a centralized handler, each route has its own error handling. The try/catch, the status code, the message, the logging—all repeated. A change to the error format requires editing every route. The centralized handler is one function, and the format is one place.

The consistency problem. The client expects a consistent error format. The { error: { code, message } } object is easier to handle than the various shapes the routes produce. The centralized handler enforces the format, and the client has one parser.

The leaking problem. A route that catches an error and sends err.message may leak the internal details: the stack trace, the SQL query, the file path. The centralized handler decides what the client sees. The internal details are logged, and the client receives the sanitized message.

The async problem. Express 4 does not catch the rejected promises from the async handlers. A rejected promise in an async route becomes an unhandled rejection, and the request hangs. Express 5 catches the rejections automatically. In Express 4, the async handlers need a wrapper—express-async-handler, express-async-errors, or a custom asyncHandler—that catches the rejection and passes it to next(err) .

The operational problem. The error log is the record of what failed. The centralized handler logs the error with the request context: the method, the URL, the user, the timestamp. The log is the input to the monitoring and the debugging.

The cleanup problem. The error may leave the resources behind: the uploaded file, the database transaction, the open connection. The centralized handler performs the cleanup. The multer file, for example, must be removed when the request fails after the upload .


a. The error-handling signature and the placement

The error-handling middleware has four arguments.

app.use((err, req, res, next) => {
  console.error(err.stack)
  res.status(500).json({ error: 'Internal Server Error' })
})

The four arguments distinguish the error handler from the regular middleware. The regular middleware has three. Express checks the arity, and the four-argument function is treated as the error handler .

The error handler must be registered after all the routes and the other middleware.

app.use(express.json())
app.use('/api', apiRouter)
app.use(notFoundHandler)
app.use(errorHandler)  // last

The order is the reason. The error handler catches the errors from the routes and the middleware that are registered before it. If the error handler is registered before the routes, the routes do not reach it .

The error handler can be registered multiple times.

app.use((err, req, res, next) => {
  if (err instanceof ValidationError) {
    return res.status(400).json({ error: err.message })
  }
  next(err)  // pass to the next handler
})

app.use((err, req, res, next) => {
  console.error(err)
  res.status(500).json({ error: 'Internal Server Error' })
})

The first handler handles the specific errors, and the next(err) passes the rest to the second. The chain of the error handlers is the way to specialize the handling .

The error handler can be registered on a specific path.

app.use('/api', (err, req, res, next) => {
  res.status(500).json({ error: 'API error' })
})

The path-scoped error handler handles the errors from the routes under that path. The global handler handles the rest .


b. The custom error class and the status code

The centralized handler needs to know the status code and the message. The Error class does not have the status code. The custom error class adds it.

class AppError extends Error {
  constructor(message, statusCode) {
    super(message)
    this.statusCode = statusCode
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'
    this.isOperational = true

    Error.captureStackTrace(this, this.constructor)
  }
}

The AppError extends Error and adds the statusCode, the status, and the isOperational flag. The Error.captureStackTrace preserves the stack trace.

The route throws the error.

app.get('/users/:id', async (req, res, next) => {
  const user = await User.findById(req.params.id)
  if (!user) {
    throw new AppError('User not found', 404)
  }
  res.json(user)
})

The centralized handler checks the statusCode.

app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500
  const message = err.isOperational ? err.message : 'Internal Server Error'

  if (statusCode >= 500) {
    console.error(err)
  }

  res.status(statusCode).json({
    error: {
      message,
      status: err.status || 'error'
    }
  })
})

The isOperational flag distinguishes the expected errors (the 404, the 400) from the unexpected ones (the null dereference, the database crash). The operational errors send their message. The unexpected errors send the generic message, and the full error is logged.

The custom error classes for the specific cases.

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 404)
  }
}

class ValidationError extends AppError {
  constructor(field, message) {
    super(`Validation failed for ${field}: ${message}`, 400)
    this.field = field
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super(message, 401)
  }
}

The NotFoundError, the ValidationError, and the UnauthorizedError are the specific cases. The centralized handler matches on the type.

app.use((err, req, res, next) => {
  if (err instanceof ValidationError) {
    return res.status(400).json({
      error: {
        message: err.message,
        field: err.field
      }
    })
  }
  if (err instanceof NotFoundError) {
    return res.status(404).json({ error: { message: err.message } })
  }
  // ...
})

The instanceof checks the type and the specific fields are included in the response.


c. The async wrapper and the error propagation

Express 4 does not catch the rejected promises from the async handlers. The asyncHandler wrapper catches the rejection and passes it to next(err).

const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next)
}

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id)
  if (!user) throw new NotFoundError('User')
  res.json(user)
}))

The asyncHandler takes the async function and returns the regular middleware that catches the rejection. The error reaches the centralized handler.

The express-async-handler package provides the same wrapper.

const asyncHandler = require('express-async-handler')

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id)
  res.json(user)
}))

The express-async-errors package patches the Express router and catches the rejections automatically, without the wrapper.

require('express-async-errors')

app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id)
  res.json(user)
})

The express-async-errors is the simplest, but it is a patch of the Express internals. The express-async-handler is the explicit wrapper. The choice is the preference.

Express 5 catches the rejected promises automatically. The asyncHandler wrapper is not needed in Express 5.

// Express 5
app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id)
  res.json(user)
})

The rejection from the async function is caught by the Express 5 router and passed to the error handler.

The try/catch in the async route is the alternative to the wrapper. The catch passes the error to next(err).

app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id)
    res.json(user)
  } catch (err) {
    next(err)
  }
})

The try/catch is verbose, and the wrapper is the cleaner solution.


Complete Example Session

// ============================================
// PART 1: BASIC ERROR HANDLER
// ============================================
app.use((err, req, res, next) => {
  console.error(err.stack)
  res.status(500).json({ error: 'Internal Server Error' })
})
// ============================================
// PART 2: CUSTOM ERROR CLASS
// ============================================
class AppError extends Error {
  constructor(message, statusCode) {
    super(message)
    this.statusCode = statusCode
    this.isOperational = true
    Error.captureStackTrace(this, this.constructor)
  }
}
// ============================================
// PART 3: SPECIFIC ERROR CLASSES
// ============================================
class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 404)
  }
}

class ValidationError extends AppError {
  constructor(field, message) {
    super(`Validation failed for ${field}: ${message}`, 400)
    this.field = field
  }
}
// ============================================
// PART 4: CENTRALIZED HANDLER
// ============================================
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500
  const message = err.isOperational ? err.message : 'Internal Server Error'

  if (statusCode >= 500) {
    console.error(err)
  }

  res.status(statusCode).json({
    error: {
      message,
      status: err.status || 'error'
    }
  })
})
// ============================================
// PART 5: ASYNC WRAPPER
// ============================================
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next)
}
// ============================================
// PART 6: USING THE WRAPPER
// ============================================
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id)
  if (!user) throw new NotFoundError('User')
  res.json(user)
}))
// ============================================
// PART 7: EXPRESS-ASYNC-ERRORS
// ============================================
require('express-async-errors')

app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id)
  if (!user) throw new NotFoundError('User')
  res.json(user)
})
// ============================================
// PART 8: MULTIPLE ERROR HANDLERS
// ============================================
app.use((err, req, res, next) => {
  if (err instanceof ValidationError) {
    return res.status(400).json({ error: err.message, field: err.field })
  }
  next(err)
})

app.use((err, req, res, next) => {
  res.status(err.statusCode || 500).json({ error: err.message })
})
// ============================================
// PART 9: CLEANUP IN THE HANDLER
// ============================================
app.use((err, req, res, next) => {
  if (req.file) {
    fs.unlink(req.file.path, () => {})
  }
  res.status(err.statusCode || 500).json({ error: err.message })
})
// ============================================
// PART 10: 404 HANDLER
// ============================================
app.use((req, res, next) => {
  next(new NotFoundError('Route'))
})

The ten parts covered the basic error handler, the custom error class, the specific error classes, the centralized handler, the async wrapper, using the wrapper, express-async-errors, the multiple error handlers, the cleanup, and the 404 handler.


Quick Reference

Error Handler Signature

ArgumentsType
(req, res, next)Regular middleware
(err, req, res, next)Error handler

Placement

PositionResult
After routesCatches the errors
Before routesNever reached
Path-scopedCatches the path errors
MultipleChained with next(err)

Custom Error Class

PropertyPurpose
messageThe error message
statusCodeThe HTTP status
status'fail' or 'error'
isOperationalExpected vs unexpected

Async Handling

MethodExpress 4Express 5
try/catch + next(err)WorksWorks
asyncHandler wrapperWorksWorks
express-async-errorsWorksNot needed
Native promise catchNoYes

Common Status Codes

CodeMeaning
400Bad request
401Unauthorized
403Forbidden
404Not found
409Conflict
422Unprocessable
500Server error

Best Practices

✅ Do This:

// Use the four-argument signature
app.use((err, req, res, next) => { })                          // ✅

// Register the error handler last
app.use(errorHandler)                                          // ✅

// Use the custom error class with the status code
class AppError extends Error {
  constructor(message, statusCode) { }
}                                                              // ✅

// Distinguish the operational and the unexpected errors
const message = err.isOperational ? err.message : 'Internal Server Error' // ✅

// Wrap the async handlers
app.get('/users', asyncHandler(async (req, res) => { }))        // ✅

// Clean up the resources in the handler
if (req.file) fs.unlink(req.file.path, () => {})                // ✅

// Log the full error, send the sanitized message
console.error(err)                                             // ✅

❌ Don’t Do This:

// Don't use three arguments for the error handler
app.use((err, req, res) => { })                                // ❌

// Don't register the error handler before the routes
app.use(errorHandler)
app.use('/api', apiRouter)                                     // ❌

// Don't leak the stack trace to the client
res.json({ error: err.stack })                                 // ❌

// Don't forget to handle the async rejections
app.get('/users', async (req, res) => { })  // Express 4       // ⚠️

// Don't catch and ignore the errors
catch (err) { }                                                // ❌

// Don't send the internal message for the unexpected errors
res.json({ error: err.message })  // may leak                   // ⚠️

// Don't forget the 404 handler
// The unmatched routes hang without it                        // ❌

Common Pitfalls

PitfallWhy It HappensFix
Handler not calledThree argumentsUse four
Handler not reachedRegistered before routesMove to the end
Async rejection unhandledExpress 4Use the wrapper
Stack trace leakedSent the err.stackSend the sanitized message
Resources leftNo cleanupfs.unlink in the handler
404 hangsNo 404 handlerAdd before the error handler
Error swallowedEmpty catchLog and propagate

Real-World Examples

1. Basic Handler

app.use((err, req, res, next) => {
  res.status(500).json({ error: 'Internal Server Error' })
})

2. Custom Error

class AppError extends Error {
  constructor(message, statusCode) {
    super(message)
    this.statusCode = statusCode
  }
}

3. NotFoundError

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 404)
  }
}

4. ValidationError

class ValidationError extends AppError {
  constructor(field, message) {
    super(message, 400)
    this.field = field
  }
}

5. Centralized Handler

app.use((err, req, res, next) => {
  const status = err.statusCode || 500
  const message = err.isOperational ? err.message : 'Internal Server Error'
  if (status >= 500) console.error(err)
  res.status(status).json({ error: { message } })
})

6. Async Wrapper

const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next)

7. Using the Wrapper

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id)
  if (!user) throw new NotFoundError('User')
  res.json(user)
}))

8. Multiple Handlers

app.use((err, req, res, next) => {
  if (err instanceof ValidationError) {
    return res.status(400).json({ error: err.message })
  }
  next(err)
})

9. Cleanup

app.use((err, req, res, next) => {
  if (req.file) fs.unlink(req.file.path, () => {})
  next(err)
})

10. 404 Handler

app.use((req, res, next) => {
  next(new NotFoundError('Route'))
})

Visual

Error Propagation

┌─────────────────────────────────────────────────────────────┐
│  ROUTE throws an error                                      │
│    │                                                        │
│    ▼                                                        │
│  Express catches the error                                  │
│    │                                                        │
│    ▼                                                        │
│  Skips all the regular middleware and routes                │
│    │                                                        │
│    ▼                                                        │
│  Jumps to the first error handler (4 arguments)             │
│    │                                                        │
│    ▼                                                        │
│  Error handler sends the response                           │
│                                                             │
│  The handler must be registered after the routes.           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Custom Error Class

┌─────────────────────────────────────────────────────────────┐
│  AppError extends Error                                     │
│    │                                                        │
│    ├── message: 'User not found'                            │
│    ├── statusCode: 404                                      │
│    ├── status: 'fail'                                       │
│    └── isOperational: true                                  │
│                                                             │
│  The centralized handler:                                   │
│    if (err.isOperational) send err.message                  │
│    else send 'Internal Server Error'                        │
│                                                             │
│  The operational errors are the expected ones.              │
│  The unexpected ones are the bugs.                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Async Handling

┌─────────────────────────────────────────────────────────────┐
│  EXPRESS 4                                                  │
│                                                             │
│  app.get('/users', asyncHandler(async (req, res) => {       │
│    const user = await User.findById(req.params.id)          │
│    res.json(user)                                           │
│  }))                                                        │
│                                                             │
│  The wrapper catches the rejection and calls next(err).     │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  EXPRESS 5                                                  │
│                                                             │
│  app.get('/users', async (req, res) => {                    │
│    const user = await User.findById(req.params.id)          │
│    res.json(user)                                           │
│  })                                                         │
│                                                             │
│  The rejection is caught automatically.                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Multiple Error Handlers

┌─────────────────────────────────────────────────────────────┐
│  Error thrown                                               │
│    │                                                        │
│    ▼                                                        │
│  Handler 1: ValidationError?                                │
│    ├── YES ──▶ Send 400                                     │
│    └── NO ──▶ next(err)                                     │
│    │                                                        │
│    ▼                                                        │
│  Handler 2: NotFoundError?                                  │
│    ├── YES ──▶ Send 404                                     │
│    └── NO ──▶ next(err)                                     │
│    │                                                        │
│    ▼                                                        │
│  Handler 3: Default                                         │
│    └── Send 500                                             │
│                                                             │
│  The chain of the handlers specializes the handling.        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Signature(err, req, res, next)
PlacementAfter all routes
Custom classclass AppError extends Error
Status codeerr.statusCode
Operationalerr.isOperational
Async (Express 4)asyncHandler wrapper
Async (Express 5)Automatic
Multiple handlersnext(err) chains
Cleanupfs.unlink in the handler
404next(new NotFoundError())

Key takeaways:

  • The error handler has four arguments. Express identifies it by the arity. It must be registered after all the routes and the other middleware. The four-argument function is the only way Express recognizes an error handler .
  • The custom error class adds the status code and the operational flag. The AppError extends Error and adds the statusCode and the isOperational. The centralized handler uses the status code for the response and the flag to decide whether to send the message .
  • The centralized handler translates the error into a response. It sets the status, logs the error, and sends the sanitized message. The operational errors send their message; the unexpected errors send the generic message, and the full error is logged .
  • The async handlers need the wrapper in Express 4. The rejected promise is not caught by the Express 4 router. The asyncHandler wrapper catches the rejection and calls next(err). Express 5 catches the rejections automatically .
  • The multiple error handlers chain with next(err). The first handler handles the specific errors and passes the rest to the next. The last handler is the default. The chain specializes the handling without duplicating it .
  • The cleanup happens in the handler. The uploaded file, the database transaction, the open connection—the error handler removes them. The fs.unlink for the Multer file is the common case .
  • The 404 handler passes the error to the centralized handler. The next(new NotFoundError('Route')) is the pattern. The centralized handler sends the 404 response. The alternative is the direct response in the 404 handler .

Remember: The centralized error handler is the single place for the error translation, the logging, and the cleanup. The routes throw, the handler responds. The custom error class carries the status code and the operational flag. The async wrapper catches the rejections in Express 4, and Express 5 catches them natively. The multiple handlers chain, and the 404 handler passes the error to the chain. Register the handler last, use the four-argument signature, and let the routes focus on the logic. The errors are the handler’s job.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!