Picture a car factory. Raw metal rolls in one end, and a shiny finished vehicle rolls out the other. In between, dozens of stations bolt on doors, paint panels, install engines, and run quality checks — each one doing one job before handing the car down the line.
Express middleware is exactly that: an assembly line for HTTP requests. Each middleware function gets the request, does something useful (or checks that something didn't go wrong), and either passes it along or stops the line entirely. Once you really get this mental model, Express stops feeling like magic and starts feeling like engineering.
What Middleware Actually Is
At its core, middleware is just a function with a specific signature:
function myMiddleware(req, res, next) {
// Do something with the request or response
next(); // Pass control to the next middleware
}
That next parameter is the conveyor belt. Call it and the request moves forward. Don't call it and the request stalls — the client waits forever, tapping their fingers, wondering if your server fell asleep.
Express runs middleware in the order you register it with app.use(). This is not a fun fact — it's the entire game. Order matters more than almost anything else.
The Three Middleware Archetypes
Not all middleware is created equal. There are three patterns you'll see again and again:
1. Pass-through middleware — observes or augments the request, then calls next()
// Log every request that comes through
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next(); // Don't forget this or everything dies quietly
});
This is your security camera: it watches, maybe writes things down, and lets everyone through. Morgan, the popular logging library, is just a fancy version of this.
2. Gatekeeper middleware — checks a condition and either calls next() or sends an early response
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token, no entry' });
}
try {
req.user = verifyToken(token); // Attach data to req for downstream use
next();
} catch {
res.status(403).json({ error: 'Bad token, still no entry' });
}
}
// Only authenticated users reach this route
app.get('/dashboard', requireAuth, (req, res) => {
res.json({ message: `Welcome, ${req.user.name}` });
});
This is your bouncer. They check your ID, and either wave you in or tell you to go home. The crucial detail: when rejecting, use return before res.json() — otherwise Node might try to call next() and send a response, which causes the dreaded "Cannot set headers after they are sent" error.
3. Error-handling middleware — the four-parameter special
Express identifies error middleware by its arity (number of parameters). Four params = error handler. No exceptions.
// Must have exactly four parameters: err, req, res, next
app.use((err, req, res, next) => {
console.error(err.stack);
const status = err.statusCode || 500;
res.status(status).json({
error: err.message || 'Something went wrong on our end',
});
});
Register this last — after all your routes. When any middleware or route calls next(err) (passing an error object), Express skips all remaining normal middleware and jumps straight to your error handler. It's the emergency exit.
The Order Problem That Bites Everyone
Here's a mistake that causes hours of head-scratching:
// ❌ Wrong order — auth runs AFTER the route
app.get('/secret', (req, res) => {
res.json({ secret: 'the cake is a lie' });
});
app.use(requireAuth); // Too late, Express already handled /secret
// ✅ Correct order — auth runs BEFORE the route
app.use(requireAuth);
app.get('/secret', (req, res) => {
res.json({ secret: 'the cake is a lie' });
});
Middleware is a pipeline. You can't add a filter to a pipe after the water has already passed through it.
Scoping Middleware to Specific Routes
You don't have to apply middleware globally. Mount it only where it matters:
const router = express.Router();
// This auth middleware only applies to routes in this router
router.use(requireAuth);
router.get('/profile', (req, res) => { /* protected */ });
router.put('/profile', (req, res) => { /* also protected */ });
app.use('/api/v1', router);
This is how you keep your public endpoints (like /health or /login) fast and unencumbered while locking down everything else. Your /health check doesn't need to verify a JWT — it just needs to say "yes I'm alive."
The req Object Is Your Shared Clipboard
One middleware superpower is that every function in the chain shares the same req and res objects. Middleware can attach data to req for downstream handlers to use:
// Auth middleware attaches the user
req.user = decodedToken;
// Rate limiter attaches request metadata
req.rateLimit = { remaining: 42, resetTime: Date.now() + 60000 };
// Your route handler uses it all
app.get('/api/data', requireAuth, checkRateLimit, (req, res) => {
console.log(req.user.id, req.rateLimit.remaining);
});
Think of req as the sticky note that travels with the request down the assembly line. Each station can read what previous stations wrote, and add their own notes.
Practical Tips That Save Your Sanity
- Always call
next()or send a response. Never both, never neither. - Keep each middleware focused on one thing. Auth in one, logging in another, rate-limiting in a third. Single responsibility principle applies here.
- Use
next(err)to propagate errors. Don't swallow exceptions in async handlers — use a try/catch and callnext(err). - Name your middleware functions.
app.use(function(req, res, next){})is a debugging nightmare.app.use(function requireAuth(req, res, next){})shows up properly in stack traces.
For async middleware, wrap your handlers to catch promise rejections:
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users', asyncHandler(async (req, res) => {
const users = await db.query('SELECT * FROM users');
res.json(users);
}));
Express 5 handles this automatically, but if you're on Express 4 (most of the world is), this wrapper is your friend.
Wrapping Up
Middleware is the backbone of every Express application. It's what turns a raw HTTP server into something that can authenticate users, validate inputs, log requests, handle errors gracefully, and throttle abusive clients — all before your actual business logic runs a single line.
The assembly line metaphor holds up: each station does one job, hands the work forward, and trusts that the next station will do its job too. Design your middleware that way and your Express apps will be easier to debug, test, and extend.
Got a middleware pattern you swear by? Drop it in the comments — I'd love to see what creative things people are attaching to req out there.