Picture a car factory. A raw chassis rolls in at one end, and a shiny finished vehicle rolls out the other. Along the way, dozens of stations bolt on doors, paint panels, install seats, run quality checks. No single worker does everything — they each do one thing, in order, and pass it along.
That's Express middleware. And if you've been treating it like a black box or a dumping ground for "stuff that runs before routes," you're leaving serious power on the table.
What Actually Happens When a Request Hits Express
When a request arrives, Express doesn't just teleport it to your route handler. It walks the request through a pipeline of middleware functions, each one getting a crack at req and res before passing the baton via next().
app.use((req, res, next) => {
console.log(`${req.method} ${req.path} — ${Date.now()}`);
next(); // 👈 "I'm done, pass it along"
});
That next() call is the conveyor belt moving. Forget it, and the request grinds to a halt — no response, no error, just your client staring at a spinner until it times out. We've all been there at 2am wondering why the server is "broken."
Three things middleware can do:
- Inspect or mutate
req/res(add a user object, parse a body, log timing) - Short-circuit by sending a response directly (
res.status(401).json(...)) - Delegate to the next middleware or route with
next()
Order Is Everything — Seriously
Here's the trap most developers fall into: middleware runs in the order you register it. This seems obvious until 3am when your auth check is somehow running after your route handler.
// ❌ BROKEN: logger runs, but auth never fires because routes are registered first
app.get('/secret', (req, res) => res.json({ data: 'classified' }));
app.use(authMiddleware); // too late, buddy
// ✅ CORRECT: auth runs before any route can respond
app.use(authMiddleware);
app.get('/secret', (req, res) => res.json({ data: 'classified' }));
Think of it like airport security. You don't board the plane and then get your passport checked. The checkpoint comes first, full stop.
Building a Real Middleware Stack
Here's a practical, production-flavored middleware setup that shows composition working beautifully together:
import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
const app = express();
// Layer 1: Security headers (always first)
app.use(helmet());
// Layer 2: Rate limiting (before we parse anything expensive)
app.use(rateLimit({ windowMs: 60_000, max: 100 }));
// Layer 3: Body parsing (now we trust the request enough to read it)
app.use(express.json({ limit: '10kb' }));
// Layer 4: Request ID tagging (attach metadata for downstream use)
app.use((req, _res, next) => {
req.requestId = crypto.randomUUID();
next();
});
// Layer 5: Auth (now we have a parsed body and metadata if we need them)
app.use('/api', authMiddleware);
// Layer 6: Your actual routes
app.get('/api/users', getUsersHandler);
// Layer 7: Error handler (MUST be last, MUST have 4 params)
app.use((err, req, res, _next) => {
console.error(`[${req.requestId}] ${err.message}`);
res.status(err.status ?? 500).json({ error: err.message });
});
Notice how each layer has one job. helmet doesn't know about auth. authMiddleware doesn't care about rate limits. They each do their thing and hand off cleanly. This is the Unix philosophy applied to HTTP — and it makes debugging a joy because you can yank one layer out without touching the others.
The Error Handler Special Case
Express error handlers have a weird quirk: they need exactly four parameters — (err, req, res, next) — or Express won't recognize them as error handlers. Omit one argument and your errors silently fall into the void.
// ❌ This is just a regular middleware, NOT an error handler
app.use((err, req, res) => { ... });
// ✅ Four params — Express sees this as the error trap
app.use((err, req, res, next) => {
res.status(500).json({ error: 'Something went wrong' });
});
When you call next(err) anywhere in your stack (passing a value to next), Express skips all remaining regular middleware and jumps straight to the nearest error handler. This is your escape hatch for propagating errors without wrapping every async function in a try/catch.
Speaking of which — don't forget to wrap async route handlers or you'll get unhandled promise rejections that bring down your server:
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.getUsers(); // any throw goes to your error handler ✅
res.json(users);
}));
Middleware Scoping: Not Everything Needs to Run Everywhere
One underused feature: middleware can be scoped to a path or router, not just global.
const adminRouter = express.Router();
adminRouter.use(requireAdminRole); // only runs for /admin/* routes
adminRouter.get('/dashboard', getDashboardHandler);
app.use('/admin', adminRouter);
This keeps your auth logic tight. Public routes don't even touch requireAdminRole. Your main app.use stack stays lean. And when you split into microservices someday, each router is already a self-contained unit.
The Mental Model That Changes Everything
Stop thinking about middleware as "stuff before routes." Think of it as composable transformations on a request/response pair.
Each function in the pipeline has a contract: it receives (req, res, next), optionally transforms things, and either ends the request or passes it forward. Stack enough of these together and you have an entire application — auth, parsing, validation, logging, error handling — expressed as a linear sequence of single-responsibility functions.
That's elegant. That's maintainable. And when something breaks, you know exactly which station on the assembly line to inspect.
Ready to level up your middleware game? Audit your existing Express app — count how many route handlers are doing more than one thing. Extract each concern into its own middleware function and watch your routes shrink to their pure, readable core. Your future self (and your teammates) will thank you.
Hit a weird middleware ordering bug? Drop it in the comments — the community loves a good "it was next() all along" story.