Skip to main content

Skillber v1.0 is here!

Learn more

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

src/utils/errors.js
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:

src/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
// src/routes/users.js
const 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

src/middleware/errorHandler.js
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

src/app.js
const errorHandler = require("./middleware/errorHandler");
// Routes
app.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

Terminal window
npm install winston
src/utils/logger.js
const 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.log
logger.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 handler
app.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

src/server.js
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:

src/middleware/requestLogger.js
const { v4: uuidv4 } = require("uuid"); // npm install uuid
const 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 flow
asyncHandler(fn) // wrap async routes
throw new AppError("msg", 400) // throw operational errors
throw new NotFoundError("User") // custom error types
// Error handler (4 params)
app.use((err, req, res, next) => {})
// Logger
logger.info(), logger.warn(), logger.error()
// Graceful shutdown
process.on("unhandledRejection", handler)
process.on("uncaughtException", handler)
process.on("SIGTERM", handler)

Practice Exercises

  1. Async handler with error types: Build the asyncHandler wrapper and use it in a route that fetches a user. Throw NotFoundError when the user doesn’t exist, and ValidationError when input is invalid.

  2. 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.

  3. Graceful shutdown: Add handlers for uncaughtException, unhandledRejection, and SIGTERM. Close the server and database connections gracefully before exiting.