Skip to main content

Express Production Error Handling

When deploying your Express application to production, proper error handling becomes critical. In development, you might want detailed error messages to help debug issues. But in production, exposing these details could create security vulnerabilities and confuse users. This guide will show you how to implement production-ready error handling in your Express applications.

Introduction to Production Error Handling​

Production error handling differs from development error handling in several key ways:

  1. Security: Error details should be hidden from users to prevent information leakage
  2. User Experience: Friendly, non-technical error messages should be shown to users
  3. Logging: Errors need comprehensive logging for debugging without exposing details
  4. Stability: Application should recover gracefully from errors without crashing

Let's explore how to achieve these goals in Express.js.

Setting Up Environment-Based Error Handling​

First, we need to configure our application to handle errors differently based on the environment:

javascript
// app.js
const express = require('express');
const app = express();

// Set environment variable, typically done through process.env.NODE_ENV
const isProduction = process.env.NODE_ENV === 'production';

// Your routes and other middleware
app.get('/', (req, res) => {
res.send('Hello World!');
});

// Route that throws an error for demonstration
app.get('/error', (req, res) => {
throw new Error('This is a demonstration error');
});

// Error handling middleware (must be defined last)
app.use((err, req, res, next) => {
console.error(err);

if (isProduction) {
// In production, send a generic message
res.status(500).json({
message: 'Something went wrong. Please try again later.'
});
} else {
// In development, send the error details
res.status(500).json({
message: err.message,
stack: err.stack
});
}
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

Creating Custom Error Classes​

To better organize your error handling, create custom error classes:

javascript
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // Indicates this is a known operational error

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

module.exports = AppError;

Then use this custom error in your routes:

javascript
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const AppError = require('../errors/AppError');

router.get('/user/:id', async (req, res, next) => {
try {
const user = await findUser(req.params.id);

if (!user) {
// Create a 404 error
return next(new AppError('User not found', 404));
}

res.json(user);
} catch (err) {
next(err); // Pass any other errors to the error handler
}
});

module.exports = router;

Comprehensive Error Handling Middleware​

Now let's create a more robust error handling middleware:

javascript
// middleware/errorHandler.js
const AppError = require('../errors/AppError');

// For logging errors in production
const logger = require('../utils/logger'); // Implement your preferred logger

exports.errorHandler = (err, req, res, next) => {
// Clone the error object to avoid modifications
let error = { ...err };
error.message = err.message;

// Log error for server-side debugging
if (process.env.NODE_ENV === 'production') {
logger.error({
message: error.message,
stack: error.stack,
url: req.originalUrl,
method: req.method,
body: req.body,
params: req.params,
query: req.query,
user: req.user ? req.user.id : 'unauthenticated'
});
} else {
console.error('\x1b[31m%s\x1b[0m', error.stack);
}

// Handle mongoose duplicate key error
if (error.code === 11000) {
const field = Object.keys(error.keyValue)[0];
error = new AppError(`Duplicate value for ${field}. Please use another value.`, 400);
}

// Handle mongoose validation error
if (error.name === 'ValidationError') {
const messages = Object.values(error.errors).map(val => val.message);
error = new AppError(`Invalid input data: ${messages.join('. ')}`, 400);
}

// Handle JWT errors
if (error.name === 'JsonWebTokenError') {
error = new AppError('Invalid token. Please log in again.', 401);
}

// Send response
if (error.isOperational) {
// Operational, trusted errors: send message to client
return res.status(error.statusCode || 500).json({
status: 'error',
message: process.env.NODE_ENV === 'production'
? (error.statusCode >= 500 ? 'Something went wrong' : error.message)
: error.message
});
}

// Programming or unknown errors: don't leak error details in production
console.error('ERROR šŸ’„:', error);
return res.status(500).json({
status: 'error',
message: process.env.NODE_ENV === 'production'
? 'Something went wrong'
: error.message
});
};

Handling Async Errors​

Handling asynchronous errors in Express can be challenging. Let's create a wrapper to catch async errors without try/catch blocks:

javascript
// utils/catchAsync.js
module.exports = fn => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};

How to use this wrapper:

javascript
// routes/productRoutes.js
const express = require('express');
const router = express.Router();
const catchAsync = require('../utils/catchAsync');
const Product = require('../models/Product');
const AppError = require('../errors/AppError');

router.get('/products', catchAsync(async (req, res, next) => {
const products = await Product.find();

// No try/catch needed - errors will be caught and passed to error middleware
res.json({
status: 'success',
results: products.length,
data: { products }
});
}));

router.get('/products/:id', catchAsync(async (req, res, next) => {
const product = await Product.findById(req.params.id);

if (!product) {
return next(new AppError('No product found with that ID', 404));
}

res.json({
status: 'success',
data: { product }
});
}));

module.exports = router;

Handling Uncaught Exceptions and Unhandled Rejections​

For a truly robust application, you should also handle uncaught exceptions and promise rejections:

javascript
// server.js
const app = require('./app');

process.on('uncaughtException', err => {
console.error('UNCAUGHT EXCEPTION! šŸ’„ Shutting down...');
console.error(err.name, err.message, err.stack);
process.exit(1); // Exit with failure
});

const server = app.listen(process.env.PORT || 3000, () => {
console.log(`App running on port ${process.env.PORT || 3000}...`);
});

process.on('unhandledRejection', err => {
console.error('UNHANDLED REJECTION! šŸ’„ Shutting down...');
console.error(err.name, err.message, err.stack);
server.close(() => {
process.exit(1); // Exit with failure
});
});

// For SIGTERM signal (e.g., when Heroku restarts dynos)
process.on('SIGTERM', () => {
console.log('šŸ‘‹ SIGTERM RECEIVED. Shutting down gracefully');
server.close(() => {
console.log('šŸ’„ Process terminated!');
});
});

Implementing Rate Limiting for Security​

To protect your application from brute-force attacks, implement rate limiting:

javascript
// middleware/rateLimit.js
const rateLimit = require('express-rate-limit');

exports.apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
message: {
status: 'error',
message: 'Too many requests from this IP, please try again after 15 minutes'
}
});

exports.loginLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour window
max: 5, // start blocking after 5 requests
message: {
status: 'error',
message: 'Too many login attempts. Please try again after an hour'
}
});

Apply the rate limiters to your routes:

javascript
// app.js
const express = require('express');
const { apiLimiter, loginLimiter } = require('./middleware/rateLimit');
const app = express();

// Apply rate limiting to all requests
app.use('/api', apiLimiter);

// Apply stricter rate limiting to authentication routes
app.use('/api/auth/login', loginLimiter);

Real-World Example: E-commerce API Error Handling​

Let's put all these concepts together in a more complete example for an e-commerce API:

javascript
// app.js
const express = require('express');
const mongoose = require('mongoose');
const helmet = require('helmet');
const xss = require('xss-clean');
const mongoSanitize = require('express-mongo-sanitize');
const hpp = require('hpp');
const compression = require('compression');
const cors = require('cors');
const { errorHandler } = require('./middleware/errorHandler');
const AppError = require('./errors/AppError');
const productRoutes = require('./routes/productRoutes');
const orderRoutes = require('./routes/orderRoutes');

const app = express();

// Set security HTTP headers
app.use(helmet());

// Body parser
app.use(express.json({ limit: '10kb' }));

// Data sanitization against NoSQL query injection
app.use(mongoSanitize());

// Data sanitization against XSS
app.use(xss());

// Prevent parameter pollution
app.use(hpp({
whitelist: ['price', 'rating', 'category', 'brand']
}));

// Enable CORS
app.use(cors());
app.options('*', cors());

// Compress responses
app.use(compression());

// Routes
app.use('/api/v1/products', productRoutes);
app.use('/api/v1/orders', orderRoutes);

// Handle undefined routes
app.all('*', (req, res, next) => {
next(new AppError(`Can't find ${req.originalUrl} on this server!`, 404));
});

// Error handling middleware
app.use(errorHandler);

// Connect to database and start server
mongoose
.connect(process.env.DATABASE_URI)
.then(() => {
console.log('Database connection successful');
})
.catch(err => {
console.error('Database connection error', err);
process.exit(1);
});

module.exports = app;

Best Practices for Production Error Handling​

Here are some additional best practices to follow:

  1. Use a centralized error logging service such as Sentry, LogRocket, or your own logging system
  2. Implement error monitoring that alerts your team when critical errors occur
  3. Use appropriate status codes (404 for not found, 403 for forbidden, etc.)
  4. Validate input data before processing to prevent many common errors
  5. Use defensive programming to check for edge cases before they become errors
  6. Implement graceful degradation so parts of your app can still work when others fail
  7. Rotate logs to prevent disk space issues in production

Summary​

Production error handling in Express.js applications involves:

  • Creating separate handling strategies for development and production environments
  • Implementing custom error classes for better error classification
  • Using comprehensive error middleware for consistent error responses
  • Handling async errors with wrappers to avoid repetitive try/catch blocks
  • Catching uncaught exceptions and unhandled rejections
  • Implementing security measures like rate limiting
  • Following best practices for logging, monitoring, and maintenance

By implementing these strategies, you can build Express applications that are more secure, stable, and maintainable in production environments.

Additional Resources​

Exercises​

  1. Create a custom error handling middleware that logs errors to a file in production and to the console in development.
  2. Extend the AppError class to include more specific error types like ValidationError, AuthenticationError, and DatabaseError.
  3. Implement a rate limiter that has different limits for different API endpoints based on their sensitivity.
  4. Create a system that tracks 404 errors and reports them to administrators so they can fix broken links.
  5. Build a middleware that tracks error frequencies and automatically sends alerts when errors exceed normal thresholds.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)