Express Middleware: The Invisible Assembly Line Every Request Walks Through 🏭
Imagine a package arriving at an Amazon fulfillment center. It doesn't just teleport from the conveyor belt directly into a truck. It gets scanned, sorted, weighed, labelled, re-scanned, and probably handled by six different people before it leaves. Your HTTP requests work exactly the same way in Express — and most developers have no idea.
Every request that hits your Express server walks through a chain of middleware functions before it ever reaches the route handler you actually wrote. Understanding that chain transforms you from someone who copies app.use(express.json()) from Stack Overflow into someone who knows why it has to come first.
What Middleware Actually Is
A middleware function is just a function with three parameters: req, res, and next. That's it. The magic is in next — calling it passes control to the next function in the chain. Not calling it leaves your request hanging forever, staring at the ceiling.
// The simplest possible middleware
function logRequests(req, res, next) {
console.log(`${req.method} ${req.url} - ${new Date().toISOString()}`);
next(); // "I'm done, pass it along"
}
app.use(logRequests);
app.get('/hello', (req, res) => {
res.send('Hi there!');
});
When a GET /hello comes in, Express runs logRequests first. It logs the request, calls next(), and only then does your route handler run. Remove next() and the request just... dies quietly. No response, no error. Just silence. Your browser keeps spinning. Your users wonder if the internet is broken.
This is the single most common Express bug for beginners, and it bites senior devs too when they're writing custom middleware at 11 PM.
The Order Is Everything
Here's where developers get burned: middleware runs in the order you register it. This isn't a quirk — it's the entire design.
// WRONG: parser hasn't run yet, req.body is undefined
app.get('/submit', (req, res) => {
console.log(req.body); // undefined 😱
});
app.use(express.json()); // too late, champ
// CORRECT: parser runs first, then your route
app.use(express.json());
app.get('/submit', (req, res) => {
console.log(req.body); // { name: "Alice" } ✅
});
Think of it like making a sandwich. You can't add mustard before you have bread. express.json() is the bread. Your route handler is the mustard. Register them in that order or you get a confusing mess.
This also explains why auth middleware must come before protected routes, why CORS headers need to be set before the response is sent, and why error handlers always go last (more on that in a second).
Writing Middleware That Actually Does Something Useful
Let's build something real: a middleware that adds a request ID to every incoming request so you can trace it through your logs.
const { randomUUID } = require('crypto');
function requestId(req, res, next) {
// Accept an existing ID from the upstream proxy, or mint a fresh one
req.id = req.headers['x-request-id'] || randomUUID();
// Echo it back in the response so clients can correlate errors
res.setHeader('x-request-id', req.id);
next();
}
app.use(requestId);
app.get('/orders/:id', (req, res) => {
// Now every log line can include req.id for traceability
console.log(`[${req.id}] Fetching order ${req.params.id}`);
res.json({ orderId: req.params.id, requestId: req.id });
});
This tiny piece of middleware makes debugging in production dramatically easier. When a user reports "my order didn't load at 3pm", you search your logs for their request ID and find exactly what happened, step by step through your entire middleware chain.
The Error Handler: The One Middleware With Four Parameters
Express has a special convention for error-handling middleware: it takes four parameters — err, req, res, next. Express recognizes this signature and routes errors to it automatically.
// Your normal middleware and routes go here...
// Error handler MUST be last, MUST have 4 params
app.use((err, req, res, next) => {
const status = err.status || 500;
console.error(`[${req.id}] Error: ${err.message}`);
res.status(status).json({
error: {
message: err.message,
requestId: req.id,
// Don't leak stack traces in production!
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
},
});
});
To trigger it from any middleware or route, just call next(err) with an error object:
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
return next(err); // Jumps straight to error handler
}
res.json(user);
} catch (dbError) {
next(dbError); // Unexpected errors hit the error handler too
}
});
Calling next(err) skips all remaining regular middleware and routes and jumps directly to your error handler. It's like pulling the emergency brake on the assembly line — everything stops and the error gets dealt with properly.
Practical Takeaways
Understanding middleware unlocks a whole toolbox:
- Authentication: Check a JWT, attach
req.user, callnext(). Attach this to any route that needs protection. - Input validation: Validate
req.bodywith Zod or Joi in middleware so your routes stay clean. - Rate limiting: Libraries like
express-rate-limitare just middleware — they callnext()if the user is under the limit, or respond with 429 if not. - Caching: Check a cache key before hitting your database, respond early if you get a hit.
Every one of these is the same pattern: do a thing, then either call next() to continue or respond to end the chain.
The next time you see app.use(someLibrary()), you'll know exactly what's happening: you're registering another station on the assembly line, another worker in the chain that processes every request before it reaches its destination.
That's the middleware mental model — and once it clicks, a huge amount of Express "magic" suddenly makes perfect sense.
Built something clever with Express middleware? Drop it in the comments — I'm always curious what creative uses people come up with for the next() function.