Skip to content

3. Middleware

Middleware is the core Express concept. Expect at least one question on it.


1. What Is Middleware?

A function that runs between the incoming request and the response, with access to req, res and next.

js
function logger(req, res, next) {
  console.log(`${req.method} ${req.url}`);
  next();   // pass control to the next middleware
}

app.use(logger);

The Three Things Middleware Can Do

  1. Run code (log, time, count)
  2. Modify req or res (attach req.user, set headers)
  3. End the cycle (res.json(…)) or call next()

The Rule

Every middleware must either send a response or call next(). Do neither and the request hangs until the client times out. Do both and you get Cannot set headers after they are sent.

js
// ❌ Hangs forever
function broken(req, res, next) {
  if (!req.user) return;   // no response, no next()
  next();
}

// ❌ Headers already sent
function alsoBroken(req, res, next) {
  res.json({ error: "Unauthorized" });
  next();   // the next handler will try to respond again
}

// ✅
function correct(req, res, next) {
  if (!req.user) return res.status(401).json({ error: "Unauthorized" });
  next();
}

The return before res.json is what stops execution. Forgetting it is the single most common Express bug.


2. The Middleware Chain

js
app.use(helmet());          // 1
app.use(express.json());    // 2
app.use(logger);            // 3
app.get("/users", auth, getUsers);   // 4 then 5
app.use(notFound);          // 6 — only if nothing responded
app.use(errorHandler);      // 7 — only on next(err)

Execution is top to bottom, in registration order. A middleware registered after a route never runs for that route.

Ordering Rules

Must comeWhere
Security headers (helmet)First
Body parsersBefore any route reading req.body
AuthBefore protected routes
RoutesMiddle
404 handlerAfter all routes
Error handlerAbsolute last

3. Types of Middleware

Application-Level

js
app.use(logger);                    // every request
app.use("/api", apiLogger);         // only paths under /api

Router-Level

js
const router = Router();
router.use(requireAuth);            // every route in this router
router.get("/", listItems);

Route-Level

js
app.get("/admin", requireAuth, requireRole("admin"), adminHandler);

Built-In

js
express.json();
express.urlencoded({ extended: true });
express.static("public");

Third-Party

helmet, cors, morgan, express-rate-limit, compression, multer.

Error-Handling

Four parameters. Covered below.


4. Writing Middleware

Simple

js
export function requestTime(req, res, next) {
  req.startTime = Date.now();

  res.on("finish", () => {
    const ms = Date.now() - req.startTime;
    logger.info({ method: req.method, url: req.url, status: res.statusCode, ms });
  });

  next();
}

res.on("finish") fires after the response is fully sent — the correct place to measure duration.

Configurable (Middleware Factory)

The pattern interviewers ask you to write: a function that returns middleware.

js
export function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: "Not authenticated" });
    }
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

router.delete("/:id", requireAuth, requireRole("admin", "moderator"), remove);

Note the 401 vs 403 distinction — that's usually the real thing being tested.

Async Middleware

js
export const requireAuth = asyncHandler(async (req, res, next) => {
  const token = req.headers.authorization?.replace("Bearer ", "");
  if (!token) return res.status(401).json({ error: "No token" });

  const payload = jwt.verify(token, process.env.JWT_SECRET);
  req.user = await db.user.findUnique({ where: { id: payload.sub } });

  if (!req.user) return res.status(401).json({ error: "User not found" });
  next();
});

In Express 4 an async middleware that throws hangs the request — wrap it. Express 5 handles it.


5. Common Middleware

CORS

js
import cors from "cors";

app.use(cors({
  origin: (origin, cb) => {
    const allowed = ["https://app.example.com", "https://admin.example.com"];
    if (!origin || allowed.includes(origin)) return cb(null, true);
    cb(new Error("Not allowed by CORS"));
  },
  credentials: true,
  methods: ["GET", "POST", "PATCH", "DELETE"],
}));

What CORS Actually Is

A browser security mechanism. The server tells the browser which origins may read its responses. It is not server-side protection — curl, Postman and any backend ignore CORS entirely.

Saying this is worth a lot: "CORS protects the user's browser from a malicious site reading my API with their cookies. It does not protect my API. Authorisation does that."

Preflight

For non-simple requests (custom headers, PUT/DELETE, JSON content type), the browser first sends an OPTIONS request. If it fails, the real request never happens. Most "CORS errors" are a failing preflight.

Access-Control-Allow-Origin: * combined with credentials: true is invalid and browsers reject it. You must echo a specific origin.

Rate Limiting

js
import rateLimit from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
  keyGenerator: (req) => req.user?.id ?? req.ip,
});

app.use("/api", limiter);
app.use("/api/auth/login", rateLimit({ windowMs: 15 * 60 * 1000, max: 5 }));

Two details that matter:

  1. The Redis store. The default in-memory store gives each process its own counter — useless behind a load balancer or with cluster.
  2. Stricter limits on auth endpoints. 100/15min is fine for reads; 5/15min is right for login.

If you're behind a proxy, set app.set("trust proxy", 1) or req.ip is the proxy's IP and every user shares one bucket.

Compression

js
import compression from "compression";
app.use(compression());

Gzips responses over ~1 KB. Skip it if a CDN or reverse proxy already compresses — doing it twice wastes CPU.

File Uploads

js
import multer from "multer";

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 },   // 5 MB
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    cb(null, allowed.includes(file.mimetype));
  },
});

app.post("/upload", upload.single("avatar"), (req, res) => {
  // req.file — validate the actual bytes, not just the mimetype
  res.json({ size: req.file.size });
});

Security Note

file.mimetype comes from the client and is trivially forged. Check magic bytes with a library like file-type, store uploads outside the web root (or in object storage), and never use the client's filename directly — ../../etc/passwd is a path traversal.


6. Error-Handling Middleware

Four parameters. Express detects error middleware by arity — three parameters and it is treated as normal middleware.

js
app.use((err, req, res, next) => {
  //    ^^^ four params, even if next is unused
});

Complete Handler

js
export function errorHandler(err, req, res, next) {
  if (res.headersSent) return next(err);   // delegate to Express's default

  const status = err.statusCode ?? 500;
  const isOperational = err.isOperational ?? false;

  req.log?.error({ err, status }, "Request failed");

  res.status(status).json({
    error: {
      message: isOperational ? err.message : "Internal server error",
      code: err.code ?? "INTERNAL_ERROR",
      ...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
    },
  });
}

Three details worth stating:

  1. res.headersSent check — you cannot send a second response
  2. Generic message for unexpected errors — never leak internals to a client
  3. Stack traces only outside production

Reaching It From a Route

js
// Sync — Express catches thrown errors automatically
app.get("/a", (req, res) => { throw new Error("boom"); });

// Async in Express 4 — must call next(err) or wrap
app.get("/b", async (req, res, next) => {
  try { await risky(); } catch (err) { next(err); }
});

Calling next() with any argument skips all remaining normal middleware and jumps to the error handler.


7. 404 Handler

js
app.use((req, res) => {
  res.status(404).json({ error: `Cannot ${req.method} ${req.originalUrl}` });
});

Placed after all routes, before the error handler. Without it Express returns an HTML error page, which breaks JSON clients.


8. Middleware Execution Order Quiz

A classic interview exercise:

js
app.use((req, res, next) => { console.log("A"); next(); });

app.get("/test", (req, res, next) => { console.log("B"); next(); },
                 (req, res) => { console.log("C"); res.send("done"); });

app.use((req, res, next) => { console.log("D"); next(); });

Request GET /test prints:

A
B
C

"D" never runs — the response was sent in C, and middleware registered after the matched route is not reached. This trips up a lot of candidates.


9. Middleware Anti-Patterns

Anti-patternWhy it's wrong
Business logic in middlewareUntestable without fake req/res
Forgetting return before res.json()Execution continues; double response
Neither responding nor calling next()Request hangs
Error handler with 3 parametersExpress treats it as normal middleware
Heavy sync work in middlewareBlocks the event loop for every request
Auth middleware that only checks presenceVerify the signature, not just that a token exists
Global auth without an exempt listYour login and health endpoints stop working
In-memory rate limiter across many processesEach process counts separately

© 2025 DDocs · Dipak's Documentation Guide