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.
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
- Run code (log, time, count)
- Modify
reqorres(attachreq.user, set headers) - End the cycle (
res.json(…)) or callnext()
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.
// ❌ 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
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 come | Where |
|---|---|
Security headers (helmet) | First |
| Body parsers | Before any route reading req.body |
| Auth | Before protected routes |
| Routes | Middle |
| 404 handler | After all routes |
| Error handler | Absolute last |
3. Types of Middleware
Application-Level
app.use(logger); // every request
app.use("/api", apiLogger); // only paths under /apiRouter-Level
const router = Router();
router.use(requireAuth); // every route in this router
router.get("/", listItems);Route-Level
app.get("/admin", requireAuth, requireRole("admin"), adminHandler);Built-In
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
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.
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
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
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
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:
- The Redis store. The default in-memory store gives each process its own counter — useless behind a load balancer or with
cluster. - 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
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
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.
app.use((err, req, res, next) => {
// ^^^ four params, even if next is unused
});Complete Handler
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:
res.headersSentcheck — you cannot send a second response- Generic message for unexpected errors — never leak internals to a client
- Stack traces only outside production
Reaching It From a Route
// 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
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:
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-pattern | Why it's wrong |
|---|---|
| Business logic in middleware | Untestable without fake req/res |
Forgetting return before res.json() | Execution continues; double response |
Neither responding nor calling next() | Request hangs |
| Error handler with 3 parameters | Express treats it as normal middleware |
| Heavy sync work in middleware | Blocks the event loop for every request |
| Auth middleware that only checks presence | Verify the signature, not just that a token exists |
| Global auth without an exempt list | Your login and health endpoints stop working |
| In-memory rate limiter across many processes | Each process counts separately |