Skip to main content

Middleware & Error Handling

Middleware is the backbone of an Express application. Every request passes through a chain of middleware functions before reaching your route handler. In this lesson we'll build custom middleware, centralize error handling, and add essential production middleware.

How Middleware Works

Middleware functions execute in the order they are registered. Each can modify req/res, end the response, or call next() to pass control forward.

// Execution order: authMiddleware -> validateBody -> route handler
app.post("/api/users", authMiddleware, validateBody, (req, res) => {
  res.status(201).json({ data: req.body });
});

Custom Request Logger

A more robust logger that captures method, path, status, and duration:

// src/middleware/logger.ts
import type { Request, Response, NextFunction } from "express";

export function requestLogger(req: Request, res: Response, next: NextFunction) {
  const start = performance.now();
  const { method, originalUrl } = req;

  res.on("finish", () => {
    const duration = (performance.now() - start).toFixed(1);
    const status = res.statusCode;
    const level = status >= 400 ? "ERROR" : "INFO";
    console.log(`[${level}] ${method} ${originalUrl} ${status} ${duration}ms`);
  });

  next();
}

Centralized Error Handling

Never let unhandled errors crash your server. Create a custom error class and a centralized error handler.

Custom Error Class

// src/utils/app-error.ts
export class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string
  ) {
    super(message);
    this.name = "AppError";
  }
}

Error Handling Middleware

Express error middleware has four parameters. The err parameter tells Express this is an error handler:

// src/middleware/error-handler.ts
import type { Request, Response, NextFunction } from "express";
import { AppError } from "../utils/app-error.js";

export function errorHandler(
  err: Error,
  _req: Request,
  res: Response,
  _next: NextFunction
) {
  // Known operational errors
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
      },
    });
    return;
  }

  // Unexpected errors — log the full stack, return a generic message
  console.error("Unhandled error:", err);
  res.status(500).json({
    error: {
      code: "INTERNAL_ERROR",
      message: "Something went wrong",
    },
  });
}

Using Errors in Routes

Throw AppError in your routes and let the error handler catch it:

// src/routes/users.ts
import { Router } from "express";
import { AppError } from "../utils/app-error.js";

const router = Router();

router.get("/:id", (req, res) => {
  const user = users.find((u) => u.id === Number(req.params.id));
  if (!user) {
    throw new AppError(404, "NOT_FOUND", "User not found");
  }
  res.json({ data: user });
});

export default router;

Catching Async Errors

Express 4 doesn't catch errors from async handlers automatically. Wrap them or use Express 5 which handles this natively:

// Wrapper for Express 4
function asyncHandler(fn: Function) {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

router.get(
  "/:id",
  asyncHandler(async (req: Request, res: Response) => {
    const user = await db.users.findById(req.params.id);
    if (!user) {
      throw new AppError(404, "NOT_FOUND", "User not found");
    }
    res.json({ data: user });
  })
);

Register the Error Handler

The error handler must be registered after all routes:

// src/index.ts
import express from "express";
import { requestLogger } from "./middleware/logger.js";
import { errorHandler } from "./middleware/error-handler.js";
import usersRouter from "./routes/users.js";

const app = express();

app.use(express.json());
app.use(requestLogger);

app.use("/api/users", usersRouter);

// Must be last
app.use(errorHandler);

app.listen(3000);

CORS Configuration

Cross-Origin Resource Sharing allows browsers to call your API from different domains:

npm install cors
npm install -D @types/cors
import cors from "cors";

// Allow all origins (development)
app.use(cors());

// Restrict origins (production)
app.use(
  cors({
    origin: ["https://myapp.com", "https://admin.myapp.com"],
    methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
    allowedHeaders: ["Content-Type", "Authorization"],
    credentials: true,
  })
);

Rate Limiting

Prevent abuse by limiting how many requests a client can make:

npm install express-rate-limit
import rateLimit from "express-rate-limit";

// Global rate limiter — 100 requests per 15 minutes per IP
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 100,
  standardHeaders: "draft-7",
  legacyHeaders: false,
  message: {
    error: {
      code: "RATE_LIMIT_EXCEEDED",
      message: "Too many requests, try again later",
    },
  },
});

app.use(limiter);

// Stricter limiter for auth routes
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 10,
});

app.use("/api/auth", authLimiter);

Request ID Middleware

Assign a unique ID to every request for tracing across logs:

// src/middleware/request-id.ts
import { randomUUID } from "node:crypto";
import type { Request, Response, NextFunction } from "express";

export function requestId(req: Request, res: Response, next: NextFunction) {
  const id = req.headers["x-request-id"]?.toString() || randomUUID();
  req.headers["x-request-id"] = id;
  res.set("X-Request-Id", id);
  next();
}

Putting It All Together

Here's the recommended middleware order for a production API:

// src/index.ts
import express from "express";
import cors from "cors";
import rateLimit from "express-rate-limit";
import { requestId } from "./middleware/request-id.js";
import { requestLogger } from "./middleware/logger.js";
import { errorHandler } from "./middleware/error-handler.js";
import usersRouter from "./routes/users.js";

const app = express();

// 1. Security & parsing
app.use(cors({ origin: "https://myapp.com" }));
app.use(express.json({ limit: "10kb" }));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }));

// 2. Tracing & logging
app.use(requestId);
app.use(requestLogger);

// 3. Routes
app.use("/api/users", usersRouter);

// 4. Error handling (must be last)
app.use(errorHandler);

app.listen(3000);