Middleware Patterns in Node.js
Middleware is the backbone of Express.js. It’s the mechanism that lets you inject logic into the request-response cycle, making your application flexible and modular. This tutorial covers the fundamental middleware patterns you need to build dependable Node.js applications.
What Is Middleware?
Middleware functions are functions that have access to three things: the request object (req), the response object (res), and the next function in the application’s request-response cycle.
A middleware function can:
- Execute any code
- Make changes to the request or response objects
- End the request-response cycle
- Call the next middleware in the stack
If a middleware doesn’t end the cycle, it must call next() to pass control to the next middleware. Forgetting to call next() is a common mistake that leaves requests hanging.
The Basic Middleware Signature
Every middleware function follows this signature:
function middlewareName(req, res, next) {
// Your logic here
next(); // Pass control to the next middleware
}
The three parameters are conventionally named req, res, and next. You can name them anything, but sticking to the convention makes your code readable to other developers.
Creating Your First Middleware
Here’s a simple logging middleware that logs when a request hits your server:
const myLogger = function (req, res, next) {
console.log('Request received:', req.method, req.url);
next();
};
app.use(myLogger);
The order matters. If you load this middleware after a route, the request never reaches it because the route handler terminates the cycle first.
Middleware That Modifies the Request
You can add properties to the request object that downstream handlers can use:
const requestTime = function (req, res, next) {
req.requestTime = Date.now();
next();
};
app.use(requestTime);
app.get('/', (req, res) => {
res.send(`Page rendered at: ${new Date(req.requestTime)}`);
});
This pattern is useful for adding timestamps, user information from authentication, or any data computed early in the cycle.
Configurable Middleware
Create reusable middleware by exporting a function that accepts options:
// my-middleware.js
module.exports = function (options) {
return function (req, res, next) {
// Use options.foo, options.bar here
if (options.required && !req.headers[options.required]) {
return res.status(400).send('Missing required header');
}
next();
};
};
// Usage
const mw = require('./my-middleware');
app.use(mw({ required: 'x-api-key' }));
This pattern is how popular middleware like cors and helmet work — they accept configuration and return a middleware function.
Error Handling Middleware
Error handling middleware is special. It takes four parameters instead of three:
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('Something went wrong!');
});
Express identifies error handlers by their four-parameter signature. When you call next(err), Express skips all regular middleware and routes, going straight to your error handler.
Async Middleware
Async middleware requires special handling. If your middleware returns a Promise, Express 5 will automatically call next() with the rejection reason:
// Express 5 (automatic)
async function validateCookies(req, res, next) {
await checkCookies(req.cookies);
next();
}
// Express 4 (manual error handling)
function validateCookies(req, res, next) {
checkCookies(req.cookies)
.then(() => next())
.catch(next); // Pass error to Express
}
In Express 4, always catch async errors and pass them to next(). Otherwise, unhandled rejections crash your server.
Chaining Middleware
The power of middleware comes from chaining. Each middleware can:
- Perform its task and call
next() - Send a response and end the cycle
- Pass an error to
next(err)
app.use(checkAuth); // 1. Verify user is authenticated
app.use(loadUser); // 2. Load user data into req.user
app.use(logRequest); // 3. Log the request
app.get('/profile', (req, res) => { // 4. Handle the route
res.json(req.user);
});
This chain pattern is how authentication, logging, parsing, and routing all work together cleanly.
Third-Party Middleware
Express includes minimal built-in middleware. For common tasks, use proven third-party packages:
| Package | Purpose |
|---|---|
morgan | HTTP request logging |
helmet | Security headers |
cors | Cross-origin resource sharing |
cookie-parser | Parse cookies |
body-parser | Parse request bodies |
const helmet = require('helmet');
const morgan = require('morgan');
app.use(helmet());
app.use(morgan('tiny'));
Ordering and Short-Circuiting
Middleware order is part of the design, not just an implementation detail. A logging layer usually belongs near the start so it can observe the full request, while authentication and validation should happen before expensive work. When a middleware sends a response early, it short-circuits the rest of the chain. That is useful for rejects, cached responses, and guard rails, but it should be used deliberately so later handlers are not skipped by accident.
This is why teams benefit from naming middleware by responsibility rather than by placement. A request can move through several steps, and each step should be easy to describe in one sentence. If a handler both validates and formats the response, it becomes harder to reuse and harder to test. Small, purpose-built middleware pieces are easier to reorder when the application changes.
Testing Middleware in Isolation
Middleware is much easier to trust when you test it with a fake request, a fake response, and a spy for next(). That lets you confirm whether it calls through, stops early, or sets the right fields on the request object. You do not need a running server for most of those checks, which keeps the feedback loop fast.
It also helps to write one test for the success path and one for the rejection path. Middleware often fails in the edge cases: missing headers, malformed body data, or unexpected user input. A short test suite that covers those edges will catch more bugs than a long manual checklist. Good middleware tests are narrow, predictable, and focused on behavior rather than setup noise.
When to Split Middleware
If a middleware function starts carrying branching logic for unrelated cases, split it into smaller functions. One function can attach metadata, another can enforce rules, and a third can format an error response. That separation keeps each unit easy to reason about and makes it clearer which piece is responsible when a request fails.
Splitting also makes it easier to reuse the same logic in routes that have different needs. A middleware that only knows about one step in the request lifecycle can move between routes without dragging along extra assumptions. That flexibility matters once the app grows beyond a single demo flow.
Middleware Lifecycles
Middleware should be thought of as part of a request lifecycle, not as a random collection of callbacks. Each one receives the request at a certain point, does one small job, and either passes control onward or ends the response. That mental model makes it easier to decide where a new piece belongs and whether it should run before or after other checks.
Lifecycle thinking also helps with cleanup. If middleware attaches listeners, stores temporary values, or opens a resource, it needs a matching end point. Without that habit, request handling can leave behind work that no longer matters. A good middleware chain starts and ends cleanly, even when a request fails early.
Request Lifecycles
Middleware works best when you can draw the path of a request from start to finish. A request may begin with logging, move through validation, pass into the route handler, and then end with a response formatter. When the lifecycle is visible like that, it becomes much easier to place new middleware in the right spot.
That view also helps when you debug a bad request. You can ask which step saw the request first, which step changed it, and which step decided to stop. Those answers point you toward the failure faster than looking at the whole chain all at once.
See Also
- Building a Web App with Express — Full Express application setup
- Designing a REST API with Node.js — Building APIs with proper middleware
- Modules and npm — Understanding Node.js module system