Node.js Error Handling: Express Error Middleware
Error handling is where many Express applications fall short. Without proper error middleware, uncaught exceptions crash your Node.js process, leaving users with broken connections and your server in...
Key Insights
- Express error middleware uses a four-parameter signature
(err, req, res, next)and must be placed after all other middleware to catch errors from the entire request pipeline. - Synchronous errors are automatically passed to error handlers, but asynchronous errors in routes require explicit handling with try-catch blocks or promise rejection handlers.
- Production-ready error handling requires custom error classes, centralized error middleware, and environment-aware responses that hide implementation details from clients while logging full details for debugging.
Introduction to Express Error Handling
Error handling is where many Express applications fall short. Without proper error middleware, uncaught exceptions crash your Node.js process, leaving users with broken connections and your server in an undefined state. Express provides a built-in mechanism for handling errors through specialized middleware, but it requires deliberate implementation.
When an error occurs in Express and isn’t caught, the default behavior depends on the error type. Synchronous errors in routes get caught by Express and trigger the default error handler, which sends a stack trace to the client in development mode. Asynchronous errors, however, will crash your application:
const express = require('express');
const app = express();
// This will crash the server
app.get('/crash', async (req, res) => {
const data = await Promise.reject(new Error('Unhandled async error'));
res.json(data);
});
app.listen(3000);
This route will terminate your Node.js process because the promise rejection isn’t caught. The solution is implementing proper error-handling middleware.
Anatomy of Error Middleware
Error-handling middleware in Express is distinguished by its four-parameter signature. While regular middleware uses (req, res, next), error middleware requires (err, req, res, next):
// Regular middleware - 3 parameters
app.use((req, res, next) => {
console.log('Regular middleware');
next();
});
// Error middleware - 4 parameters
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: {
message: err.message
}
});
});
Express identifies error middleware by counting the function parameters. If a middleware function has four parameters, Express treats it as error-handling middleware and only invokes it when errors occur.
Placement is critical. Error middleware must be defined after all routes and regular middleware. Express processes middleware in the order they’re defined, so your error handler needs to be last to catch errors from everything above it:
const express = require('express');
const app = express();
// Regular middleware
app.use(express.json());
// Routes
app.get('/users', (req, res) => {
throw new Error('Something went wrong');
});
// Error middleware MUST come last
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
app.listen(3000);
Catching Synchronous vs Asynchronous Errors
Express automatically catches synchronous errors thrown in route handlers and passes them to error middleware:
// Synchronous error - automatically caught
app.get('/sync-error', (req, res) => {
throw new Error('This error is caught automatically');
});
Asynchronous errors require explicit handling. For async/await functions, wrap your code in try-catch blocks:
// Async/await with try-catch
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
throw new Error('User not found');
}
res.json(user);
} catch (error) {
next(error); // Pass to error middleware
}
});
For promise-based code, use .catch() to forward errors:
// Promise-based with .catch()
app.get('/posts/:id', (req, res, next) => {
Post.findById(req.params.id)
.then(post => {
if (!post) {
throw new Error('Post not found');
}
res.json(post);
})
.catch(next); // Shorthand for .catch(err => next(err))
});
The key is always calling next(error) to pass errors to your error-handling middleware.
Creating Custom Error Classes
Generic Error objects lack context. Custom error classes let you attach HTTP status codes, error codes, and other metadata:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404);
this.errorCode = 'RESOURCE_NOT_FOUND';
}
}
class ValidationError extends AppError {
constructor(message, errors = []) {
super(message, 400);
this.errorCode = 'VALIDATION_ERROR';
this.errors = errors;
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401);
this.errorCode = 'UNAUTHORIZED';
}
}
Now your routes can throw semantic errors:
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json(user);
} catch (error) {
next(error);
}
});
Centralized Error Handler Implementation
A production-ready error handler needs to handle different error types, log appropriately, and return safe responses:
const errorHandler = (err, req, res, next) => {
// Default to 500 if no status code is set
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
// Log error details for debugging
console.error('ERROR:', {
message: err.message,
stack: err.stack,
statusCode: err.statusCode,
path: req.path,
method: req.method
});
// Development: send full error details
if (process.env.NODE_ENV === 'development') {
return res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
}
// Production: hide implementation details
if (err.isOperational) {
// Trusted operational error: send message to client
return res.status(err.statusCode).json({
status: err.status,
message: err.message,
errorCode: err.errorCode
});
}
// Programming or unknown error: don't leak details
console.error('PROGRAMMING ERROR:', err);
return res.status(500).json({
status: 'error',
message: 'Something went wrong'
});
};
module.exports = errorHandler;
This handler distinguishes between operational errors (expected errors like validation failures) and programming errors (bugs). Operational errors get sent to clients; programming errors get logged but return generic messages.
Error Handling Patterns and Best Practices
Wrapping every async route in try-catch is tedious. Create a wrapper utility to eliminate boilerplate:
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Use it to wrap async routes
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);
}));
app.post('/users', asyncHandler(async (req, res) => {
const { email, name } = req.body;
if (!email || !name) {
throw new ValidationError('Email and name are required');
}
const user = await User.create({ email, name });
res.status(201).json(user);
}));
Here’s a complete Express app structure with proper error handling:
const express = require('express');
const app = express();
// Middleware
app.use(express.json());
// Request logging
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next();
});
// Routes
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
}));
app.post('/api/users', asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json(user);
}));
// 404 handler for undefined routes
app.use((req, res, next) => {
next(new NotFoundError('Route'));
});
// Error handler (must be last)
app.use(errorHandler);
app.listen(3000);
Testing Error Middleware
Testing error scenarios ensures your error handling works correctly:
const request = require('supertest');
const app = require('./app');
describe('Error Handling', () => {
test('returns 404 for non-existent routes', async () => {
const response = await request(app)
.get('/api/nonexistent')
.expect(404);
expect(response.body).toHaveProperty('message');
expect(response.body.errorCode).toBe('RESOURCE_NOT_FOUND');
});
test('returns 400 for validation errors', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: '' }) // Invalid data
.expect(400);
expect(response.body.errorCode).toBe('VALIDATION_ERROR');
});
test('hides error details in production', async () => {
process.env.NODE_ENV = 'production';
const response = await request(app)
.get('/api/error')
.expect(500);
expect(response.body).not.toHaveProperty('stack');
expect(response.body.message).toBe('Something went wrong');
});
});
Proper error handling isn’t optional—it’s the difference between a resilient production application and one that crashes under unexpected conditions. Implement custom error classes, use centralized error middleware, and always handle async errors explicitly. Your users and your on-call schedule will thank you.