Middleware
Middleware is the backbone of every Express application. Master it and you understand how the entire framework works — routing, auth, error handling, everything.
The Core Concept
A middleware function is any function with this signature:
// Regular middleware
(req, res, next) => { ... }
// Error-handling middleware — 4 arguments, MUST be exactly 4
(err, req, res, next) => { ... }
Every incoming request flows through a pipeline of these functions in the order they were registered. Each function can:
- Modify
reqorres(add properties, set headers) - End the cycle by calling
res.json()/res.send() - Pass forward by calling
next()— "I'm done, keep going" - Skip to error handling by calling
next(err)— "something went wrong"
next() nor sends a response, the request hangs forever. The client waits, times out, and you see no error. This is the most common middleware bug.
The Full Request Pipeline
Here's what actually happens for a POST /api/v1/users request to a production Express app:
req.body. Calls next().req.id, sets X-Request-ID header. Calls next().res finish event to log method, path, status, duration.X-Frame-Options, X-Content-Type-Options, CSP, etc. Calls next().Origin header. Adds Access-Control-Allow-Origin. Handles OPTIONS preflight. Calls next().next(). If over, calls next(new TooManyRequestsError()).Authorization: Bearer <token>, verifies JWT. Attaches req.user. On failure calls next(new UnauthorizedError()).req.body / req.params / req.query against Zod schema. On failure calls next(new ValidationError(...)).res.json(). Done.next(err) was called anywhere above. Logs the error, sends consistent error response.Types of Middleware
Application-level
Registered with app.use(). Runs on every request unless path-scoped.
Router-level
Registered with router.use(). Only runs on routes mounted to that router.
Route-level
Passed as arguments to a specific route handler. Only runs for that exact route.
Error-handling
Exactly 4 arguments (err, req, res, next). Only called when next(err) is called.
Built-in
express.json(), express.urlencoded(), express.static()
Third-party
morgan, helmet, cors, express-rate-limit, cookie-parser, compression…
Writing Middleware: All Patterns
1. Transform / Attach Data
// Attach a unique request ID for tracing across logs
const { randomUUID } = require('crypto');
const requestId = (req, res, next) => {
req.id = req.headers['x-request-id'] ?? randomUUID();
res.set('X-Request-ID', req.id);
next(); // ← must call next or request hangs
};
app.use(requestId);
2. Guard / Reject Early
// Authentication — verify JWT, attach user to req
const jwt = require('jsonwebtoken');
const { UnauthorizedError } = require('../errors/AppError');
const authenticate = (req, res, next) => {
const authHeader = req.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return next(new UnauthorizedError('No token provided'));
}
const token = authHeader.slice(7);
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next(); // token valid — proceed
} catch (err) {
next(new UnauthorizedError('Invalid or expired token'));
}
};
// Role-based authorisation — build on top of authenticate
const requireRole = (...roles) => (req, res, next) => {
if (!roles.includes(req.user?.role)) {
return next(new ForbiddenError(`Requires role: ${roles.join(' or ')}`));
}
next();
};
// Usage
router.delete('/users/:id',
authenticate, // must be logged in
requireRole('admin'), // must be admin
deleteUserHandler
);
3. Logging / Observability
// Structured request logger (production-grade)
const requestLogger = (req, res, next) => {
const start = Date.now();
// 'finish' fires when the response is fully sent
res.on('finish', () => {
const duration = Date.now() - start;
const level = res.statusCode >= 500 ? 'error'
: res.statusCode >= 400 ? 'warn'
: 'info';
console[level](JSON.stringify({
type: 'request',
requestId: req.id,
method: req.method,
path: req.path,
status: res.statusCode,
duration,
ip: req.ip,
userAgent: req.get('user-agent'),
userId: req.user?.id,
}));
});
next();
};
4. Middleware Factory (Higher-Order Middleware)
// A factory is a function that RETURNS a middleware function.
// This lets you configure middleware with parameters.
const cache = (ttlSeconds) => {
const store = new Map(); // in production use Redis
return (req, res, next) => {
const key = req.originalUrl;
const cached = store.get(key);
if (cached) {
res.set('X-Cache', 'HIT');
return res.json(cached);
}
// Wrap res.json to capture and cache the response
const originalJson = res.json.bind(res);
res.json = (data) => {
store.set(key, data);
setTimeout(() => store.delete(key), ttlSeconds * 1000);
res.set('X-Cache', 'MISS');
return originalJson(data);
};
next();
};
};
// Usage — cache this route's response for 60 seconds
router.get('/stats', cache(60), statsHandler);
Error-Handling Middleware — The Full Picture
This is the most misunderstood part of Express. Error middleware is fundamentally different from regular middleware:
| Property | Regular Middleware | Error Middleware |
|---|---|---|
| Signature | (req, res, next) — 3 args | (err, req, res, next) — exactly 4 |
| When it runs | On every matching request in order | Only when next(err) is called |
| How to trigger it | Normal request flow | Call next(new Error(...)) from any middleware or handler |
| Registration order | Anywhere sensible | Must be last — after all routes |
| Typical use | Parse, log, authenticate, validate | Format + send error responses, log errors |
(err, req, res) with 3 args, Express treats it as regular middleware and your errors will never reach it. The next parameter must be declared even if you never call it.
Triggering the Error Handler
// Three ways to reach the error handler:
// 1. next(err) — synchronous middleware/routes
app.get('/users/:id', (req, res, next) => {
const user = db.findUser(req.params.id);
if (!user) return next(new NotFoundError('User'));
res.json(user);
});
// 2. throw inside an async handler (with express-async-errors installed)
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw new NotFoundError('User'); // caught by express-async-errors
res.json(user);
});
// 3. next(err) in async middleware (without express-async-errors)
app.use(async (req, res, next) => {
try {
await somethingAsync();
next();
} catch (err) {
next(err); // forward to error handler
}
});
A Production-Grade Error Handler
// src/middleware/errorHandler.js
const { AppError } = require('../errors/AppError');
// Map common third-party error codes to HTTP status codes
const normaliseError = (err) => {
// Prisma unique constraint (duplicate email, etc.)
if (err.code === 'P2002') {
return new AppError(`${err.meta?.target} already exists`, 409, 'CONFLICT');
}
// Prisma record not found
if (err.code === 'P2025') {
return new AppError('Record not found', 404, 'NOT_FOUND');
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
return new AppError('Invalid token', 401, 'UNAUTHORIZED');
}
if (err.name === 'TokenExpiredError') {
return new AppError('Token expired', 401, 'TOKEN_EXPIRED');
}
// express.json() body parse error
if (err.type === 'entity.parse.failed') {
return new AppError('Invalid JSON body', 400, 'INVALID_JSON');
}
return err;
};
const errorHandler = (err, req, res, next) => { // ← 4 args, MUST be 4
const error = normaliseError(err);
// Log everything — full context for debugging
const logPayload = {
type: 'error',
requestId: req.id,
method: req.method,
path: req.path,
status: error.statusCode ?? 500,
message: error.message,
stack: error.stack,
userId: req.user?.id,
};
if (!error.isOperational) {
// Non-operational = programmer bug — log at highest severity
console.error('[FATAL]', JSON.stringify(logPayload));
} else {
console.warn(JSON.stringify(logPayload));
}
// Never send error details for unknown (programmer) errors
if (!error.isOperational) {
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId: req.id, // include so users can report it
}
});
}
res.status(error.statusCode).json({
error: {
code: error.code,
message: error.message,
...(error.details && { details: error.details }),
}
});
};
module.exports = errorHandler;
Async Middleware — The Critical Problem
Express was built before async/await. It does not automatically catch rejected promises from async middleware or routes. If you forget to handle this, errors silently disappear:
app.get('/users',
async (req, res) => {
// if this throws...
const u = await db.findAll();
// ...Express never sees it
// error handler NOT called
// request hangs forever
}
);app.get('/users',
async (req, res, next) => {
try {
const u = await db.findAll();
res.json(u);
} catch(err) {
next(err); // forward!
}
}
);// ✅ Option B: asyncHandler wrapper — eliminates try/catch boilerplate
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users', asyncHandler(async (req, res) => {
const u = await db.findAll(); // throw auto-forwarded to error handler
res.json(u);
}));
// ✅ Option C: express-async-errors (recommended for new projects)
// npm install express-async-errors
// Add ONE line at the top of app.js:
require('express-async-errors'); // patches Express to catch all async rejections
// Now async handlers work natively — no wrapper needed
app.get('/users', async (req, res) => {
const u = await db.findAll(); // throw → error handler. Just works.
res.json(u);
});
Essential Third-Party Middleware Stack
npm install express-async-errors morgan helmet cors express-rate-limit \
compression cookie-parser hpp
// src/app.js — production middleware stack in recommended order
require('express-async-errors'); // must be first
const express = require('express');
const morgan = require('morgan');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const hpp = require('hpp'); // HTTP Parameter Pollution protection
const app = express();
// 1. Trust proxy (must be first setting if behind load balancer)
app.set('trust proxy', 1);
// 2. Security headers (helmet before everything else)
app.use(helmet());
// 3. CORS — configure allowed origins explicitly
app.use(cors({
origin: (origin, cb) => {
const allowed = process.env.ALLOWED_ORIGINS.split(',');
if (!origin || allowed.includes(origin)) cb(null, true);
else cb(new Error('Not allowed by CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
}));
// 4. Rate limiting — global
app.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200,
standardHeaders: true, // send RateLimit-* headers
legacyHeaders: false,
}));
// 5. Body parsers (after rate limit — don't parse huge bodies of rate-limited requests)
app.use(express.json({ limit: '10kb' })); // 10kb body limit prevents DoS
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
app.use(cookieParser(process.env.COOKIE_SECRET));
// 6. HTTP Parameter Pollution protection
// Prevents ?sort=asc&sort=desc&sort=name polluting req.query.sort
app.use(hpp());
// 7. Response compression
app.use(compression());
// 8. Request logging (after body parse so logger can read req.body if needed)
const logFormat = process.env.NODE_ENV === 'production' ? 'combined' : 'dev';
app.use(morgan(logFormat));
// 9. Request ID
app.use(requestId);
// ── Routes ────────────────────────────────────
app.use('/api/v1', require('./routes'));
// ── 404 (after all routes) ────────────────────
app.use((req, res) =>
res.status(404).json({ error: { code: 'NOT_FOUND', message: `Cannot ${req.method} ${req.path}` }})
);
// ── Error handler (must be LAST, must have 4 args) ─
app.use(require('./middleware/errorHandler'));
module.exports = app;
Middleware Execution Order — The Key Rules
- Middleware executes in the order it is registered with
app.use()or the route method - If a middleware calls
next(), the next matching middleware runs. If it sends a response, the chain stops - If a middleware calls
next(err), Express skips all remaining regular middleware and jumps directly to the nearest error handler - Error handlers must be registered after all routes — they can only catch errors from middleware registered before them
- You can have multiple error handlers in sequence — useful for separating logging from response sending
Multiple Error Handlers — Separation of Concerns
// Two error handlers in sequence is valid and useful
// First: logging
app.use((err, req, res, next) => {
logger.error({ err, requestId: req.id }); // send to Sentry, Datadog etc.
next(err); // ← pass to the next error handler
});
// Second: response formatting
app.use((err, req, res, next) => {
res.status(err.statusCode ?? 500).json({ error: { message: err.message } });
});
🧠 Check Your Understanding
Go Deeper
Primary source: Express.js — Using Middleware
Error handling: Express.js — Error Handling Guide — read the entire page.
Security package: helmetjs.github.io — understand what each header does.
Ask your teacher: "Walk me through exactly what happens when next(err) is called inside a route handler." or "Why does middleware order matter? Show me a bug caused by wrong order."