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