Node.js & Express

Error Handling in Express

28 min Lesson 13 of 40

Error Handling in Express

Proper error handling is essential for building robust and user-friendly APIs. Express provides a powerful error handling mechanism that allows you to catch, process, and respond to errors gracefully, improving both user experience and application maintainability.

Types of Errors

  • Synchronous Errors: Thrown in route handlers and caught automatically
  • Asynchronous Errors: Errors in promises that need explicit handling
  • Validation Errors: Invalid user input (422 status)
  • Authentication Errors: Unauthorized access (401/403 status)
  • Not Found Errors: Resource doesn't exist (404 status)
  • Server Errors: Unexpected server issues (500 status)

Basic Error Handling

Express automatically catches synchronous errors:

const express = require('express'); const app = express(); // Synchronous error - automatically caught app.get('/users/:id', (req, res) => { const user = users.find(u => u.id === parseInt(req.params.id)); if (!user) { // Throwing error - Express catches it throw new Error('User not found'); } res.json(user); }); // Default error handler (must be defined last) app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ success: false, message: 'Something went wrong!' }); }); app.listen(3000);
Note: Error handling middleware must have four parameters (err, req, res, next) and must be defined after all other middleware and routes.

Handling Asynchronous Errors

Async errors need to be explicitly passed to next():

// Without async/await - use .catch() app.get('/users/:id', (req, res, next) => { User.findById(req.params.id) .then(user => { if (!user) { throw new Error('User not found'); } res.json(user); }) .catch(next); // Pass error to error handler }); // With async/await - wrap in 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 error to error handler } });

Custom Error Classes

Create custom error classes for different error types:

// errors/AppError.js class AppError extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; this.isOperational = true; // Operational errors we can trust Error.captureStackTrace(this, this.constructor); } } module.exports = AppError; // errors/NotFoundError.js const AppError = require('./AppError'); class NotFoundError extends AppError { constructor(resource = 'Resource') { super(`${resource} not found`, 404); } } module.exports = NotFoundError; // errors/ValidationError.js const AppError = require('./AppError'); class ValidationError extends AppError { constructor(errors) { super('Validation failed', 422); this.errors = errors; } } module.exports = ValidationError; // errors/UnauthorizedError.js const AppError = require('./AppError'); class UnauthorizedError extends AppError { constructor(message = 'Unauthorized access') { super(message, 401); } } module.exports = UnauthorizedError;

Using Custom Error Classes

const NotFoundError = require('./errors/NotFoundError'); const ValidationError = require('./errors/ValidationError'); const UnauthorizedError = require('./errors/UnauthorizedError'); // Route handlers app.get('/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); } }); app.post('/users', async (req, res, next) => { try { // Validation const errors = validateUser(req.body); if (errors.length > 0) { throw new ValidationError(errors); } const user = await User.create(req.body); res.status(201).json(user); } catch (error) { next(error); } }); app.get('/admin', checkAuth, (req, res, next) => { if (!req.user.isAdmin) { return next(new UnauthorizedError('Admin access required')); } res.json({ message: 'Admin panel' }); });

Centralized Error Handler

Create a comprehensive error handling middleware:

// middleware/errorHandler.js const AppError = require('../errors/AppError'); const errorHandler = (err, req, res, next) => { // Set default values err.statusCode = err.statusCode || 500; err.status = err.status || 'error'; // Development vs Production error responses if (process.env.NODE_ENV === 'development') { sendErrorDev(err, res); } else { sendErrorProd(err, res); } }; const sendErrorDev = (err, res) => { // Send detailed error in development res.status(err.statusCode).json({ success: false, status: err.status, message: err.message, error: err, stack: err.stack, errors: err.errors || undefined }); }; const sendErrorProd = (err, res) => { // Operational, trusted error: send message to client if (err.isOperational) { res.status(err.statusCode).json({ success: false, status: err.status, message: err.message, errors: err.errors || undefined }); } // Programming or unknown error: don't leak details else { // Log error for debugging console.error('ERROR:', err); // Send generic message res.status(500).json({ success: false, status: 'error', message: 'Something went wrong' }); } }; module.exports = errorHandler; // Use in app.js const errorHandler = require('./middleware/errorHandler'); // ... routes ... // Error handler (must be last) app.use(errorHandler);
Best Practice: Never expose sensitive error details (stack traces, database errors) in production. Log them server-side instead.

Async Error Handler Wrapper

Create a wrapper to avoid try-catch blocks in every async route:

// utils/asyncHandler.js const asyncHandler = (fn) => { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; }; module.exports = asyncHandler; // Usage - no more try-catch needed! const asyncHandler = require('./utils/asyncHandler'); const NotFoundError = require('./errors/NotFoundError'); 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 user = await User.create(req.body); res.status(201).json(user); })); // Errors are automatically caught and passed to error handler!

Handling Specific Error Types

Handle different error types appropriately:

const errorHandler = (err, req, res, next) => { // MongoDB CastError (invalid ID format) if (err.name === 'CastError') { err = new AppError(`Invalid ${err.path}: ${err.value}`, 400); } // MongoDB Duplicate Key Error if (err.code === 11000) { const field = Object.keys(err.keyValue)[0]; err = new AppError(`Duplicate value for ${field}`, 409); } // MongoDB Validation Error if (err.name === 'ValidationError') { const errors = Object.values(err.errors).map(e => e.message); err = new ValidationError(errors); } // JWT Errors if (err.name === 'JsonWebTokenError') { err = new UnauthorizedError('Invalid token'); } if (err.name === 'TokenExpiredError') { err = new UnauthorizedError('Token expired'); } // Multer file upload errors if (err.name === 'MulterError') { if (err.code === 'LIMIT_FILE_SIZE') { err = new AppError('File size too large', 413); } } // Send error response err.statusCode = err.statusCode || 500; err.status = err.status || 'error'; if (process.env.NODE_ENV === 'development') { sendErrorDev(err, res); } else { sendErrorProd(err, res); } };

404 Not Found Handler

Handle routes that don't exist:

const NotFoundError = require('./errors/NotFoundError'); // ... all routes ... // 404 handler - must be after all routes app.all('*', (req, res, next) => { next(new NotFoundError(`Route ${req.originalUrl} not found`)); }); // Error handler - must be last app.use(errorHandler);

Error Logging

Log errors for debugging and monitoring:

const winston = require('winston'); // Configure logger const logger = winston.createLogger({ level: 'error', format: winston.format.json(), transports: [ new winston.transports.File({ filename: 'error.log', level: 'error' }), new winston.transports.File({ filename: 'combined.log' }) ] }); // Add console transport in development if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple() })); } // Error handler with logging const errorHandler = (err, req, res, next) => { // Log error logger.error({ message: err.message, stack: err.stack, statusCode: err.statusCode, timestamp: new Date().toISOString(), method: req.method, url: req.originalUrl, ip: req.ip, userId: req.user?.id }); // Send response err.statusCode = err.statusCode || 500; if (process.env.NODE_ENV === 'development') { sendErrorDev(err, res); } else { sendErrorProd(err, res); } };
Warning: Never log sensitive information like passwords, tokens, or personal data in error logs. Sanitize data before logging.

Handling Unhandled Rejections

Catch unhandled promise rejections globally:

// app.js const app = require('./app'); const server = app.listen(3000, () => { console.log('Server running on port 3000'); }); // Handle unhandled promise rejections process.on('unhandledRejection', (err) => { console.error('UNHANDLED REJECTION! Shutting down...'); console.error(err.name, err.message); // Close server gracefully server.close(() => { process.exit(1); }); }); // Handle uncaught exceptions process.on('uncaughtException', (err) => { console.error('UNCAUGHT EXCEPTION! Shutting down...'); console.error(err.name, err.message); process.exit(1); });

Error Response Formatting

Consistent error response format:

// Production error response format { "success": false, "status": "fail", "message": "User not found", "errors": [ { "field": "email", "message": "Invalid email format" } ] } // Development error response format (includes debugging info) { "success": false, "status": "error", "message": "Database connection failed", "error": { "name": "MongoNetworkError", "message": "Connection timeout" }, "stack": "Error: Connection timeout\n at ..." }
Exercise: Implement comprehensive error handling for a products API:
  • Create custom error classes: NotFoundError, ValidationError, UnauthorizedError, ConflictError
  • Implement asyncHandler wrapper to eliminate try-catch blocks
  • Create centralized error handler middleware with dev/prod modes
  • Handle MongoDB errors (CastError, duplicate key, validation)
  • Implement 404 handler for non-existent routes
  • Add error logging with Winston
  • Test error handling by triggering various error scenarios

API Error Response Standards

Follow industry standards for API error responses:

  • HTTP Status Codes: Use appropriate status codes (400, 401, 403, 404, 422, 500)
  • Error Messages: Clear, actionable messages for users
  • Error Codes: Optional custom error codes for client-side handling
  • Field-Level Errors: For validation, specify which fields have errors
  • Consistent Format: Same structure across all endpoints
  • No Sensitive Data: Never expose passwords, tokens, or internal details
  • Documentation: Document all possible error responses
// Example comprehensive error response { "success": false, "status": "fail", "message": "Validation failed", "errorCode": "VALIDATION_ERROR", "errors": [ { "field": "email", "message": "Invalid email format", "code": "INVALID_EMAIL" }, { "field": "password", "message": "Password must be at least 8 characters", "code": "PASSWORD_TOO_SHORT" } ], "timestamp": "2024-01-15T10:30:00.000Z", "path": "/api/users" }

Error Handling Best Practices

  • Always use error handling middleware, don't handle errors in routes
  • Create custom error classes for different error types
  • Use asyncHandler wrapper to simplify async error handling
  • Distinguish between operational and programming errors
  • Never expose sensitive information in error messages
  • Log all errors with context (timestamp, user, request details)
  • Return different error details in development vs production
  • Use appropriate HTTP status codes consistently
  • Provide actionable error messages that help users fix issues
  • Handle unhandled rejections and uncaught exceptions
  • Implement graceful shutdown on critical errors
  • Document all possible error responses in API documentation

In the next lesson, we'll learn how to implement authentication using JSON Web Tokens (JWT) to secure our API endpoints and manage user sessions.