Middleware is the single most important concept in Express. Almost everything you do in an Express application is middleware — body parsing, authentication, logging, CORS, compression, validation, error handling. A middleware function is simply a function that receives (req, res, next) and either completes the response or calls next() to pass control to the next function in the chain. Understanding how the middleware chain works — execution order, how data is attached to req, how errors propagate, and how to write your own — gives you complete control over every request that enters your application.
Middleware Types
| Type | Registered With | Runs For | Example |
|---|---|---|---|
| Application-level | app.use(fn) |
Every request | Body parser, CORS, logging |
| Router-level | router.use(fn) |
Requests matched by this router | Auth on all task routes |
| Route-level | app.get('/path', fn1, fn2) |
Only this specific route | Validate before controller |
| Error-handling | app.use((err, req, res, next) => {}) |
When next(err) is called |
Global error formatter |
| Built-in | app.use(express.json()) |
Configured request types | Body parsing, static files |
| Third-party | app.use(cors()) |
All or specific requests | cors, helmet, morgan, compression |
The Middleware Signature
| Signature | Type | Called When |
|---|---|---|
(req, res, next) |
Regular middleware | Always — for request processing |
(err, req, res, next) |
Error-handling middleware | Only when next(err) is called |
next() Behaviour
| Call | Effect |
|---|---|
next() |
Pass to the next matching middleware or route handler |
next(err) |
Skip all remaining middleware and jump to the nearest error handler |
next('route') |
Skip remaining handlers for the current route, try the next matching route |
| Not called | The request hangs — no response is sent (client times out) |
app.use() before routes runs for every request. Middleware registered after a route only runs for requests that do not match any earlier route. This means you must register essential middleware (body parsers, CORS, logging) at the very top of app.js, before any route definitions.req object inside middleware is the standard Express pattern for sharing data between middleware functions and route handlers. Authentication middleware reads the JWT, verifies it, and attaches the user: req.user = decodedPayload. Every subsequent middleware and route handler in the same request chain can then access req.user without re-reading the token.(err, req, res, next). If you define it with three parameters — even if you never use next — Express will treat it as regular middleware and it will never be called when next(err) is invoked. The four-parameter signature is how Express identifies error handlers. If your error handler does not call next(err) for errors it cannot handle, errors will be silently swallowed.Basic Example — Writing Middleware
const express = require('express');
const app = express();
// ── Basic middleware structure ────────────────────────────────────────────
const logger = (req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next(); // MUST call next() or the request hangs
};
const timer = (req, res, next) => {
req.startTime = Date.now(); // attach data to req for later use
res.on('finish', () => {
console.log(`${req.method} ${req.path} — ${Date.now() - req.startTime}ms`);
});
next();
};
// ── Application-level middleware — runs for every request ─────────────────
app.use(express.json());
app.use(logger);
app.use(timer);
// ── Route-level middleware — only this route ──────────────────────────────
const validateId = (req, res, next) => {
const { id } = req.params;
if (!/^[0-9a-fA-F]{24}$/.test(id)) {
return next(new Error('Invalid MongoDB ObjectId format'));
}
next();
};
app.get('/api/tasks/:id', validateId, (req, res) => {
res.json({ id: req.params.id });
});
// ── Authentication middleware ─────────────────────────────────────────────
const jwt = require('jsonwebtoken');
const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // attach user to req — available downstream
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ success: false, message: 'Token expired' });
}
return res.status(401).json({ success: false, message: 'Invalid token' });
}
};
// ── Router-level middleware — protects all task routes ────────────────────
const taskRouter = express.Router();
taskRouter.use(authenticate); // all routes in this router require auth
taskRouter.get('/', (req, res) => {
res.json({ userId: req.user.id }); // req.user set by authenticate above
});
app.use('/api/tasks', taskRouter);
// ── Middleware array — reusable combination ───────────────────────────────
const { body, validationResult } = require('express-validator');
const createTaskRules = [
body('title').trim().notEmpty().withMessage('Title is required'),
body('priority').isIn(['low', 'medium', 'high']).withMessage('Invalid priority'),
];
const handleValidation = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, errors: errors.array() });
}
next();
};
app.post('/api/tasks', authenticate, createTaskRules, handleValidation,
async (req, res) => {
// Only reaches here if authenticated and validation passed
res.status(201).json({ success: true });
}
);
// ── Error-handling middleware — MUST have 4 parameters ────────────────────
app.use((err, req, res, next) => {
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
res.status(status).json({
success: false,
message: process.env.NODE_ENV === 'production' ? 'Server Error' : message,
});
});
Middleware Execution Order — Visualised
Incoming Request: POST /api/tasks { title: "Buy milk" }
|
v
┌─────────────────────────────────────────────────────────┐
│ app.use(express.json()) — parse JSON body │
│ app.use(cors()) — add CORS headers │
│ app.use(helmet()) — add security headers │
│ app.use(morgan('dev')) — log request │
└────────────────────┬────────────────────────────────────┘
|
v
┌─────────────────────────────────────────────────────────┐
│ router.use(authenticate) — verify JWT, set req.user │
└────────────────────┬────────────────────────────────────┘
|
v
┌─────────────────────────────────────────────────────────┐
│ POST '/' — createTaskRules[] — validate body │
│ — handleValidation — check errors │
│ — controller.create — create in DB, respond │
└────────────────────┬────────────────────────────────────┘
|
(if next(err) called)
v
┌─────────────────────────────────────────────────────────┐
│ app.use(errorHandler) — format and send error │
└─────────────────────────────────────────────────────────┘
|
v
Response: 201 { success: true, data: {...} }
How It Works
Step 1 — Every Middleware Gets the Same req and res Objects
The same req and res objects are passed through the entire middleware chain for one request. Any middleware can read from req and write to it. Authentication middleware writes req.user. Timing middleware writes req.startTime. Logging middleware reads req.method and req.path. They all see the same object, making data sharing between middleware functions simple and idiomatic.
Step 2 — next() Is the Pipeline Connector
Calling next() transfers control to the next registered middleware or route handler. Not calling next() (and not sending a response) leaves the client’s request hanging indefinitely until it times out. Every middleware must either send a response — res.json(), res.send() — or call next(). Sending a response and calling next() in the same middleware causes the “headers already sent” error.
Step 3 — next(err) Jumps to the Error Handler
Passing any truthy value to next(err) causes Express to skip all remaining regular middleware and route handlers and jump directly to the first error-handling middleware (the one with four parameters). This is the correct way to propagate errors in Express. The asyncHandler utility wraps async functions so any thrown error is automatically passed to next(err).
Step 4 — Built-in Middleware Factories Return Middleware Functions
express.json() is a factory function — it takes configuration options and returns a middleware function. The same is true for cors(options), helmet(), morgan('dev'), and most npm middleware packages. Calling the factory configures the middleware; the returned function is what Express actually calls for each request.
Step 5 — Middleware Can Be Reused Across Multiple Routes
Because middleware is just a function, you can apply it selectively. Pass it as a second argument to a specific route: app.get('/admin', requireAdmin, adminHandler). Pass it to a router: router.use(authenticate). Or register it globally: app.use(logger). This composability is what makes Express so flexible — you assemble the exact pipeline each route needs.
Real-World Example: Complete Middleware Stack
// middleware/authenticate.js
const jwt = require('jsonwebtoken');
const User = require('../models/user.model');
const authenticate = async (req, res, next) => {
try {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: 'Authentication required' });
}
const token = header.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.id).select('-password').lean();
if (!user) return res.status(401).json({ success: false, message: 'User no longer exists' });
req.user = user; // attach to req for downstream use
next();
} catch (err) {
const message = err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token';
res.status(401).json({ success: false, message });
}
};
module.exports = authenticate;
// middleware/asyncHandler.js
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
let status = err.status || 500;
let message = err.message || 'Internal Server Error';
// Mongoose validation error
if (err.name === 'ValidationError') {
status = 400;
message = Object.values(err.errors).map(e => e.message).join(', ');
}
// Mongoose duplicate key
if (err.code === 11000) {
status = 409;
const field = Object.keys(err.keyPattern)[0];
message = `${field} already exists`;
}
// Mongoose bad ObjectId
if (err.name === 'CastError') {
status = 400;
message = `Invalid ${err.path}: ${err.value}`;
}
const isDev = process.env.NODE_ENV !== 'production';
res.status(status).json({
success: false,
message: isDev ? message : (status < 500 ? message : 'Server Error'),
...(isDev && status === 500 && { stack: err.stack }),
});
};
module.exports = errorHandler;
Common Mistakes
Mistake 1 — Forgetting to call next() or send a response
❌ Wrong — request hangs, client eventually times out:
const checkFeatureFlag = (req, res, next) => {
if (!featureEnabled) {
console.log('Feature disabled');
// Missing: return res.status(403).json(...) OR next()
// The request just hangs here forever
}
next();
};
✅ Correct — always end the chain:
const checkFeatureFlag = (req, res, next) => {
if (!featureEnabled) {
return res.status(403).json({ message: 'Feature not available' });
}
next();
};
Mistake 2 — Error handler with 3 parameters is never called
❌ Wrong — Express ignores this as an error handler:
// 3 parameters — Express treats this as regular middleware!
app.use((err, req, res) => {
res.status(500).json({ message: err.message });
});
✅ Correct — error handlers MUST have exactly 4 parameters:
// 4 parameters — Express identifies this as an error handler
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ message: err.message });
});
Mistake 3 — Calling next() after sending a response
❌ Wrong — response sent and next() called — headers already sent error:
app.get('/users', (req, res, next) => {
res.json({ users: [] });
next(); // response already sent — any downstream handler will error
});
✅ Correct — never call next() after sending a response:
app.get('/users', (req, res, next) => {
return res.json({ users: [] }); // return immediately — no next()
});
Quick Reference
| Task | Code |
|---|---|
| Global middleware | app.use(middlewareFn) |
| Path-scoped middleware | app.use('/api', middlewareFn) |
| Route-level middleware | app.get('/path', mw1, mw2, handler) |
| Router-level middleware | router.use(authenticate) |
| Pass to next middleware | next() |
| Trigger error handler | next(new Error('message')) or next(err) |
| Attach data to request | req.user = decodedToken |
| Error handler signature | (err, req, res, next) => {} — exactly 4 params |
| Wrap async handler | asyncHandler(async (req, res) => { ... }) |