Skip to content

How to Implement Centralized Error Handling Middleware in Node.js Express? (Complete Guide)

I got tired. Tired of writing try-catch blocks in every single route handler. Tired of the same error handling logic copied and pasted across hundreds of endpoints. Tired of inconsistent error responses flooding my API.

If you feel the same, this guide is for you.

The Problem

Look at this typical Express route:

app.get('/users/:id', async (req, res, next) => {
try {
const user = await getUserById(req.params.id)
res.json(user)
} catch (error) {
next(error) // Gotta remember this!
}
})

Now multiply that by 50 routes. Every single one needs the same try-catch boilerplate. Miss one next(error) call and your server crashes silently. That’s not sustainable.

I needed a solution: one centralized error handler that catches everything.

The Solution: Express Error Middleware

Express has a special middleware type for errors. It takes 4 arguments instead of 3:

app.use((err, req, res, next) => {
console.error(err)
res.status(500).json({ error: 'Something went wrong' })
})

The problem is: Express 4.x doesn’t automatically catch async errors. You still need to call next(error) manually.

Here’s what changed everything for me.

Express 5.x: Native Async Error Handling

Express 5 (released in late 2024) fixed this. Now async route handlers automatically pass rejected promises to the error middleware:

// Express 5.x - just works!
app.get('/users/:id', async (req, res) => {
const user = await getUserById(req.params.id)
res.json(user)
// Error automatically flows to error middleware
})
// Centralized error handler (must be LAST!)
app.use((err, req, res, next) => {
console.error(err)
res.status(err.status || 500).json({
success: false,
error: err.message,
code: err.code || 'INTERNAL_ERROR'
})
})

No try-catch. No next() calls. Just write your business logic.

Express 4.x: The Wrapper Pattern

Still on Express 4? I was there too. Here’s the workaround that saved me hundreds of lines:

const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next)
// Now your routes are clean
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await getUserById(req.params.id)
res.json(user)
}))

Or use the popular express-async-handler package:

const asyncHandler = require('express-async-handler')
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await getUserById(req.params.id)
res.json(user)
}))

Both achieve the same thing: rejected promises automatically go to your error middleware.

Custom Error Classes: Structure Your Errors

Here’s where it gets interesting. I created custom error classes to make error handling meaningful:

class AppError extends Error {
constructor(message, statusCode, code) {
super(message)
this.statusCode = statusCode
this.code = code
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'
this.isOperational = true
Error.captureStackTrace(this, this.constructor)
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404, 'NOT_FOUND')
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 400, 'VALIDATION_ERROR')
}
}

Now my routes speak my language:

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

The Production Setup

After 2 years in production, here’s what I settled on:

Request Flow:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Route │ ──> │ Business │ ──> │ Response │
│ Handler │ │ Logic │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ (async error) │ │
└───────────────────┴────────────────────┘
v
┌─────────────┐
│ Error │
│ Middleware │
└─────────────┘
┌──────────────┼──────────────┐
v v v
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Logger │ │ Response │ │ Monitor │
│ (Pino) │ │ JSON │ │ (Sentry) │
└─────────┘ └──────────┘ └──────────┘

The error middleware handles everything:

app.use((err, req, res, next) => {
err.statusCode = err.statusCode || 500
err.status = err.status || 'error'
// Different handling for operational vs programming errors
if (err.isOperational) {
logger.warn({ message: err.message, code: err.code })
} else {
logger.error({ message: err.message, stack: err.stack })
}
const response = {
success: false,
status: err.status,
message: err.message,
code: err.code
}
// Stack trace only in development
if (process.env.NODE_ENV === 'development') {
response.stack = err.stack
}
res.status(err.statusCode).json(response)
})

Why This Matters

Here’s what changed after I implemented this:

BeforeAfter
try-catch in every routeZero try-catch
Inconsistent error formatsSame response structure everywhere
Error logging scatteredSingle log point
Maintenance nightmareChange once, applies everywhere

The biggest win? My team stopped thinking about error handling. We focused on business logic, and errors just worked.

Which Should You Use?

  • Express 5.x: Use it. Native async error handling is clean.
  • Express 4.x: Use express-async-handler or the wrapper pattern.
  • Both: Create custom error classes for your domain.

I upgraded to Express 5 recently, and removing the last asyncHandler wrappers felt like spring cleaning.


Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments