Error Handling & Logging
Checking access...
Proper error handling separates robust production APIs from fragile ones. This page covers how to handle errors consistently in Express applications.
Custom Error Classes
class AppError extends Error { constructor(message, statusCode = 500) { super(message); this.name = "AppError"; this.statusCode = statusCode; this.isOperational = true; // distinguishes from programming errors Error.captureStackTrace(this, this.constructor); }}
class NotFoundError extends AppError { constructor(resource = "Resource") { super(`${resource} not found`, 404); this.name = "NotFoundError"; }}
class ValidationError extends AppError { constructor(details) { super("Validation failed", 422); this.name = "ValidationError"; this.details = details; }}
class AuthenticationError extends AppError { constructor(message = "Authentication required") { super(message, 401); this.name = "AuthenticationError"; }}
class ForbiddenError extends AppError { constructor(message = "You don't have permission") { super(message, 403); this.name = "ForbiddenError"; }}
module.exports = { AppError, NotFoundError, ValidationError, AuthenticationError, ForbiddenError,};Async Error Wrapper
Express doesn’t catch errors from async route handlers automatically. This wrapper eliminates the need for try/catch in every route:
const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next);};
module.exports = asyncHandler;
// src/routes/users.jsconst asyncHandler = require("../utils/asyncHandler");
router.get("/:id", asyncHandler(async (req, res) => { const user = await User.findById(req.params.id);
if (!user) { throw new NotFoundError("User"); }
res.json({ data: user });}));
router.post("/", asyncHandler(async (req, res) => { const user = await User.create(req.body); res.status(201).json({ data: user });}));Centralised Error Handler
const { AppError } = require("../utils/errors");
const errorHandler = (err, req, res, next) => { // Log the error console.error(`[${new Date().toISOString()}] ${err.name}: ${err.message}`); if (process.env.NODE_ENV === "development") { console.error(err.stack); }
// Multer errors if (err.code === "LIMIT_FILE_SIZE") { return res.status(400).json({ error: { message: "File too large", code: "FILE_TOO_LARGE" }, }); }
// Mongoose validation errors if (err.name === "ValidationError") { const details = Object.values(err.errors).map((e) => ({ field: e.path, message: e.message, })); return res.status(422).json({ error: { message: "Validation failed", code: "VALIDATION_ERROR", details }, }); }
// Mongoose duplicate key if (err.code === 11000) { const field = Object.keys(err.keyValue)[0]; return res.status(409).json({ error: { message: `Duplicate value for ${field}`, code: "DUPLICATE_KEY", field, }, }); }
// JWT errors if (err.name === "JsonWebTokenError") { return res.status(401).json({ error: { message: "Invalid token", code: "INVALID_TOKEN" }, }); } if (err.name === "TokenExpiredError") { return res.status(401).json({ error: { message: "Token expired", code: "TOKEN_EXPIRED" }, }); }
// Our custom operational errors if (err instanceof AppError) { return res.status(err.statusCode).json({ error: { message: err.message, code: err.name, ...(err.details && { details: err.details }), }, }); }
// Unknown errors — don't leak details in production res.status(500).json({ error: { message: process.env.NODE_ENV === "production" ? "Internal server error" : err.message, code: "INTERNAL_ERROR", }, });};
module.exports = errorHandler;Using the Error Handler
const errorHandler = require("./middleware/errorHandler");
// Routesapp.use("/api/users", usersRouter);app.use("/api/products", productsRouter);
// 404 handler (must be before error handler)app.use((req, res) => { res.status(404).json({ error: { message: `Route ${req.method} ${req.path} not found`, code: "NOT_FOUND" }, });});
// Error handler (must be last)app.use(errorHandler);Logging with Winston
npm install winstonconst winston = require("winston");const path = require("path");
const logger = winston.createLogger({ level: process.env.LOG_LEVEL || "info", format: winston.format.combine( winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), winston.format.errors({ stack: true }), winston.format.json() ), transports: [ // Console transport (always) new winston.transports.Console({ format: process.env.NODE_ENV === "production" ? winston.format.json() : winston.format.combine( winston.format.colorize(), winston.format.printf(({ timestamp, level, message, stack }) => { return `${timestamp} ${level}: ${message}${stack ? "\n" + stack : ""}`; }) ), }),
// File transports (production) ...(process.env.NODE_ENV === "production" ? [ new winston.transports.File({ filename: path.join(__dirname, "../../logs/error.log"), level: "error", maxsize: 10 * 1024 * 1024, // 10MB maxFiles: 10, }), new winston.transports.File({ filename: path.join(__dirname, "../../logs/combined.log"), maxsize: 10 * 1024 * 1024, maxFiles: 5, }), ] : []), ],});
module.exports = logger;Using the Logger
const logger = require("./utils/logger");
// Replace console.loglogger.info("Server started", { port: 3000, env: process.env.NODE_ENV });logger.warn("Rate limit approaching", { ip: req.ip });logger.error("Database connection failed", { error: err.message });
// In the error handlerapp.use((err, req, res, next) => { logger.error("Unhandled error", { method: req.method, path: req.path, error: err.message, stack: err.stack, userId: req.user?.id, }); next(err);});Uncaught Exceptions & Unhandled Rejections
const app = require("./app");const logger = require("./utils/logger");
// Handle uncaught exceptions (synchronous errors)process.on("uncaughtException", (error) => { logger.error("UNCAUGHT EXCEPTION! Shutting down...", { error: error.message, stack: error.stack }); process.exit(1);});
// Handle unhandled promise rejections (async errors)process.on("unhandledRejection", (reason, promise) => { logger.error("UNHANDLED REJECTION! Shutting down...", { error: reason?.message || reason, stack: reason?.stack, }); // Graceful shutdown server.close(() => { process.exit(1); });});
// Handle SIGTERM (e.g., from Kubernetes, PM2)process.on("SIGTERM", () => { logger.info("SIGTERM received. Shutting down gracefully..."); server.close(() => { logger.info("Server closed"); process.exit(0); });});
const PORT = process.env.PORT || 3000;const server = app.listen(PORT, () => { logger.info(`Server running on port ${PORT}`);});Request Logging Middleware
Log all requests with a correlation ID:
const { v4: uuidv4 } = require("uuid"); // npm install uuidconst logger = require("../utils/logger");
const requestLogger = (req, res, next) => { req.id = uuidv4(); const start = Date.now();
res.on("finish", () => { const duration = Date.now() - start; const logData = { requestId: req.id, method: req.method, path: req.originalUrl, status: res.statusCode, duration: `${duration}ms`, ip: req.ip, userAgent: req.get("user-agent"), };
if (res.statusCode >= 500) { logger.error("Request failed", logData); } else if (res.statusCode >= 400) { logger.warn("Request warning", logData); } else { logger.info("Request completed", logData); } });
next();};
module.exports = requestLogger;Quick Reference
// Error handling flowasyncHandler(fn) // wrap async routesthrow new AppError("msg", 400) // throw operational errorsthrow new NotFoundError("User") // custom error types
// Error handler (4 params)app.use((err, req, res, next) => {})
// Loggerlogger.info(), logger.warn(), logger.error()
// Graceful shutdownprocess.on("unhandledRejection", handler)process.on("uncaughtException", handler)process.on("SIGTERM", handler)Practice Exercises
Async handler with error types: Build the
asyncHandlerwrapper and use it in a route that fetches a user. ThrowNotFoundErrorwhen the user doesn’t exist, andValidationErrorwhen input is invalid.Winston logger setup: Configure Winston with console and file transports. Log request info (method, path, status, duration) for each request. Create separate error log files.
Graceful shutdown: Add handlers for
uncaughtException,unhandledRejection, andSIGTERM. Close the server and database connections gracefully before exiting.