Middleware as a Pipeline (and Why Order Is a Feature)
In Express.js, middleware forms a pipeline: each function can read/modify req and res, then either pass control forward with next() or end the request by sending a response. The key idea is that middleware runs in a specific order, and that order is part of your application’s behavior.
Think of the request lifecycle as a sequence:
- Global middleware (applies to all requests that reach it)
- Router-level middleware (applies to a subset of routes mounted under a path)
- Route-specific middleware (applies to one route handler)
- Error middleware (runs only when an error is passed to
next(err)or thrown in an async handler)
Express executes middleware in registration order. A later middleware will not run if an earlier middleware ends the response or never calls next().
Ordering Rules You Should Rely On
- Top-to-bottom registration:
app.use()androuter.use()are executed in the order they are declared. - Path scoping:
app.use('/api', router)only runs for paths starting with/api. - Error middleware signature: error handlers must be
(err, req, res, next); they are skipped during normal flow. - First response wins: once you call
res.send()/res.json()/res.end(), you must not attempt to send another response.
Categories of Middleware
1) Global Middleware
Global middleware is registered on the app instance and affects all downstream routes. Use it for cross-cutting concerns like request IDs, logging, security headers, parsing, and setting shared context.
import express from 'express';
import crypto from 'crypto';
const app = express();
// Global: attach a request id and expose it to downstream middleware
app.use((req, res, next) => {
const requestId = crypto.randomUUID();
res.locals.requestId = requestId;
res.setHeader('x-request-id', requestId);
next();
});
// Global: JSON parsing (must run before handlers that read req.body)
app.use(express.json());
Step-by-step ordering check:
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
- If a route needs
req.body, ensureexpress.json()is registered before the router/route. - If you want every response to have a header, set it in global middleware before routes send responses.
- If you want to log response status, attach a listener like
res.on('finish')in global middleware (no need to wrap every route).
2) Router-Level Middleware
Router-level middleware is scoped to a router. This is ideal for “all endpoints under /api/admin require auth” or “all endpoints under /api require JSON”.
import { Router } from 'express';
const adminRouter = Router();
adminRouter.use((req, res, next) => {
// Example: require an admin flag set by earlier auth middleware
if (!req.user?.isAdmin) return res.status(403).json({ error: 'forbidden' });
next();
});
adminRouter.get('/stats', (req, res) => {
res.json({ ok: true, requestId: res.locals.requestId });
});
app.use('/admin', adminRouter);
Router-level middleware helps keep route files clean and reduces repetition, while still keeping ordering explicit: middleware declared on the router runs before that router’s routes.
3) Route-Specific Middleware
Route-specific middleware is attached directly to a route. Use it when only one endpoint needs a check or transformation (e.g., validate a payload for one route).
app.post(
'/reports',
(req, res, next) => {
if (!req.body?.title) return res.status(400).json({ error: 'title required' });
next();
},
(req, res) => {
res.status(201).json({ id: 'rpt_123', title: req.body.title });
}
);
Because middleware is composable, you can extract that inline function into a reusable module and share it across routes.
4) Error Middleware
Error middleware centralizes error-to-response mapping. It runs when you call next(err) or when an async handler throws/rejects (if you wrap it appropriately).
// Error middleware must have 4 args
app.use((err, req, res, next) => {
const requestId = res.locals.requestId;
// Avoid leaking internal details
const status = err.statusCode || 500;
const message = status >= 500 ? 'internal_error' : err.message;
// If headers already sent, delegate to Express default handler
if (res.headersSent) return next(err);
res.status(status).json({ error: message, requestId });
});
Ordering requirement: error middleware should be registered after all routes, otherwise it won’t catch errors from routes declared later.
Designing Composable Middleware
Clear Contracts: Inputs, Outputs, and Responsibilities
Composable middleware is easiest to reuse when it has a clear contract:
- Reads: which fields it expects on
req,res.locals, headers, etc. - Writes: what it adds/changes (e.g.,
res.locals.user,req.user). - Control flow: when it calls
next(), when it ends the response, and what errors it passes tonext(err).
Keep middleware small: one responsibility, minimal side effects, and no hidden dependencies on global variables.
Pure-ish Middleware: Minimize Side Effects
Middleware can’t be fully pure (it interacts with I/O), but you can keep it “pure-ish” by:
- Deriving values from
reqand writing them tores.locals(or a typedreqfield) without performing unrelated actions. - Injecting dependencies (e.g., a service) via a factory rather than importing singletons everywhere.
// Dependency-injected middleware factory
export function makeLoadUser({ userService }) {
return async function loadUser(req, res, next) {
try {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return next();
const user = await userService.findByToken(token);
if (user) res.locals.user = user;
next();
} catch (err) {
next(err);
}
};
}
This middleware’s contract is explicit: it may set res.locals.user, and it never sends a response itself.
Parameterized Middleware Factories
A middleware factory is a function that returns middleware. This is the standard way to make reusable, configurable middleware without copy/paste.
Example: Require a Role
export function requireRole(role) {
return function requireRoleMiddleware(req, res, next) {
const user = res.locals.user;
if (!user) return res.status(401).json({ error: 'unauthorized' });
if (user.role !== role) return res.status(403).json({ error: 'forbidden' });
next();
};
}
// Usage
app.get('/billing', requireRole('billing_admin'), (req, res) => {
res.json({ ok: true });
});
Step-by-step:
- Ensure an upstream middleware sets
res.locals.user(orreq.user). - Use
return res.status(...)to stop execution after sending the response. - Call
next()only when the request should continue.
Example: Validation Middleware Factory
export function validateBody(requiredFields) {
return function validateBodyMiddleware(req, res, next) {
const missing = requiredFields.filter((f) => req.body?.[f] == null);
if (missing.length) {
return res.status(400).json({ error: 'missing_fields', fields: missing });
}
next();
};
}
app.post('/projects', validateBody(['name']), (req, res) => {
res.status(201).json({ id: 'p1', name: req.body.name });
});
Conditional Middleware (Run Only When Needed)
Sometimes you want middleware to run only under certain conditions (feature flags, content type, route patterns, environment). You can implement conditional middleware as a small wrapper.
export function when(predicate, middleware) {
return function conditional(req, res, next) {
if (!predicate(req, res)) return next();
return middleware(req, res, next);
};
}
const requireJson = (req) => req.is('application/json');
app.post(
'/events',
when(requireJson, express.json()),
(req, res) => res.json({ received: true })
);
Tip: conditional wrappers should always forward next correctly and should not swallow errors. If the wrapped middleware is async and throws, ensure it’s handled (see async pitfalls below).
Sharing Context Safely
Using res.locals for Request-Scoped Data
res.locals is a request-scoped object intended for passing data between middleware and handlers. It avoids global state and makes dependencies explicit.
// Attach context early
app.use((req, res, next) => {
res.locals.startTime = Date.now();
next();
});
// Use it later
app.use((req, res, next) => {
res.on('finish', () => {
const ms = Date.now() - res.locals.startTime;
// log ms, requestId, statusCode, etc.
});
next();
});
Contract pattern: document in code comments or module docs what keys your middleware sets on res.locals (e.g., user, requestId, startTime).
Attaching Typed Metadata to req (TypeScript Pattern)
In TypeScript, it’s common to attach metadata like req.user or req.context. Do it with module augmentation so downstream code is type-safe and the contract is visible.
// types/express.d.ts
import 'express-serve-static-core';
declare module 'express-serve-static-core' {
interface Request {
user?: { id: string; role: string };
requestId?: string;
}
}
// middleware/requestId.ts
import crypto from 'crypto';
export function requestId(req, res, next) {
req.requestId = crypto.randomUUID();
res.setHeader('x-request-id', req.requestId);
next();
}
// usage
app.use(requestId);
app.get('/me', (req, res) => {
res.json({ userId: req.user?.id, requestId: req.requestId });
});
Guideline: prefer either res.locals or req for a given piece of metadata, not both, to avoid confusion. Use res.locals when the data is primarily for response/templating/logging; use req when it’s part of request identity/authorization and used broadly.
Common Pitfalls and How to Avoid Them
Pitfall: Multiple Responses (“Cannot set headers after they are sent”)
This happens when code sends a response and then continues executing, or when both a middleware and a handler send responses.
// Buggy: missing return
app.get('/x', (req, res, next) => {
if (!req.query.id) res.status(400).json({ error: 'id required' });
next(); // still runs, may send again
});
Fix by returning after sending, or by using else:
app.get('/x', (req, res, next) => {
if (!req.query.id) return res.status(400).json({ error: 'id required' });
next();
});
Pitfall: Forgotten return in Async Middleware
In async middleware, forgetting to return after sending a response can lead to subsequent code running and throwing, which then triggers error middleware after a response was already sent.
app.post('/y', async (req, res, next) => {
try {
if (!req.body) return res.status(400).json({ error: 'body required' });
// ... more awaits
res.json({ ok: true });
} catch (e) {
next(e);
}
});
Notice the consistent return when responding early.
Pitfall: Hidden Dependencies Between Middleware
A route that assumes res.locals.user exists will break if the “load user” middleware isn’t mounted (or is mounted after the route). Make dependencies explicit by composing middleware arrays and exporting them together.
// authPipeline.ts
import { makeLoadUser } from './loadUser.js';
import { requireRole } from './requireRole.js';
export function makeAdminPipeline({ userService }) {
return [
makeLoadUser({ userService }),
requireRole('admin')
];
}
// usage
app.get('/admin/audit', ...makeAdminPipeline({ userService }), (req, res) => {
res.json({ ok: true });
});
This pattern makes ordering and dependencies visible at the call site.
Pitfall: Not Handling Async Errors
If an async middleware throws and you don’t catch it, Express may not route it to your error handler depending on your version and patterns. A common approach is to wrap async handlers so rejections go to next.
export const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/data', asyncHandler(async (req, res) => {
const data = await loadData();
res.json(data);
}));
Pitfall: Calling next() After Sending a Response
Calling next() after res.send() often triggers downstream middleware that may attempt to write headers/body again.
// Avoid this pattern
app.use((req, res, next) => {
res.status(404).json({ error: 'not_found' });
// next(); // don't
});
If you need centralized 404 handling, place it after all routes and do not call next() unless you intend to delegate to an error handler (typically not needed for 404).
Composition Patterns You Can Standardize
Middleware Arrays for Readability
Express accepts multiple middleware functions per route. Group them into arrays to make the pipeline explicit and reusable.
const createProjectPipeline = [
express.json(),
validateBody(['name']),
requireRole('project_admin')
];
app.post('/projects', ...createProjectPipeline, (req, res) => {
res.status(201).json({ id: 'p1', name: req.body.name });
});
“Enrichment then Use” Pattern
A reliable approach is to separate middleware that enriches the request (adds context) from middleware/handlers that uses that context.
| Stage | Example | Writes |
|---|---|---|
| Enrichment | requestId, loadUser | req.requestId, res.locals.user |
| Policy | requireRole, rateLimit | may respond with 401/403/429 |
| Business handler | createProject | sends success response |
| Error mapping | error middleware | sends error response |
This reduces hidden dependencies and clarifies where side effects (responses) are allowed.