Middleware is one of the most important concepts in backend engineering because it controls what happens between receiving an HTTP request and sending the HTTP response.
In frameworks such as Express.js, middleware functions sit inside a request-processing pipeline. Each middleware can inspect the request, modify the request or response, perform business-independent work, terminate the request, or pass control forward with next(). ([Express.js][1])
A useful mental model is:
Client
|
| HTTP Request
v
┌─────────────────────┐
│ Request Parsing │
└──────────┬──────────┘
v
┌─────────────────────┐
│ Logging Middleware │
└──────────┬──────────┘
v
┌─────────────────────┐
│ Authentication │
└──────────┬──────────┘
v
┌─────────────────────┐
│ Authorization │
└──────────┬──────────┘
v
┌─────────────────────┐
│ Validation │
└──────────┬──────────┘
v
┌─────────────────────┐
│ Route Handler │
└──────────┬──────────┘
v
┌─────────────────────┐
│ Database / Service │
└──────────┬──────────┘
v
┌─────────────────────┐
│ Error Middleware │
└──────────┬──────────┘
v
Response
4.1 What Middleware Actually Is
An Express middleware function generally receives three important arguments:
(req, res, next)
-
req— the incoming HTTP request -
res— the outgoing HTTP response -
next— function used to transfer control to the next middleware
A minimal middleware looks like this:
function logger(req, res, next) {
console.log(`${req.method} ${req.originalUrl}`);
next();
}
Register it:
app.use(logger);
The important operation is:
next();
If middleware does not send a response, end the request, or call next(), the request can remain unresolved. ([Express.js][1])
4.2 The Request-Response Lifecycle
Consider:
POST /api/users
Content-Type: application/json
Authorization: Bearer TOKEN
The backend can process it through multiple stages:
HTTP Request
|
v
Node.js HTTP Server
|
v
Express Application
|
v
Global Middleware
|
+---- Request ID
|
+---- Logging
|
+---- JSON Parsing
|
+---- CORS
|
+---- Security
|
v
Router
|
v
Authentication
|
v
Authorization
|
v
Validation
|
v
Controller
|
v
Service
|
v
Database
|
v
Controller
|
v
Response Middleware
|
v
HTTP Response
Middleware order matters. Express executes middleware in the order in which it is registered. ([Express.js][2])
For example:
app.use(authMiddleware);
app.use(validationMiddleware);
app.post("/users", createUser);
The request must pass through:
authMiddleware
↓
validationMiddleware
↓
createUser
Changing the order changes the behavior.
4.3 Middleware Can Do Four Fundamental Things
1. Execute logic
function logger(req, res, next) {
console.log(req.method);
next();
}
2. Modify the request
function attachRequestId(req, res, next) {
req.requestId = crypto.randomUUID();
next();
}
Later code can access:
console.log(req.requestId);
3. Modify the response
function securityHeaders(req, res, next) {
res.setHeader("X-API-Version", "1");
next();
}
4. End the request
function maintenanceMode(req, res, next) {
return res.status(503).json({
error: "Service temporarily unavailable"
});
}
There is no next() because the middleware has already completed the request-response cycle. Express documents middleware as being able to execute code, modify req/res, end the cycle, or call next(). ([Express.js][1])
4.4 next() Is the Control Mechanism
Think of next() as:
"Middleware finished its job.
Continue processing this request."
Example:
function authenticate(req, res, next) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({
error: "Authentication required"
});
}
next();
}
The important distinction is:
next();
means:
Continue
while:
return res.status(401).json(...);
means:
Stop
A common mistake is accidentally doing both:
if (!token) {
res.status(401).json({ error: "Unauthorized" });
}
next();
This can cause the pipeline to continue after a response has already been sent.
Prefer:
if (!token) {
return res.status(401).json({
error: "Unauthorized"
});
}
next();
4.5 Application-Level Middleware
Application-level middleware applies to the entire application or a broad section of it.
app.use(express.json());
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});
For example:
Every Request
|
v
express.json()
|
v
Request Logger
|
v
Router
Express provides built-in middleware such as express.json(), express.urlencoded(), and express.static(). ([Express.js][1])
4.6 Router-Level Middleware
Large applications should not put every middleware function directly into app.js.
Use routers:
src/
├── app.js
├── middleware/
│ ├── auth.js
│ ├── validate.js
│ └── error.js
├── routes/
│ ├── users.js
│ └── orders.js
├── controllers/
│ ├── users.js
│ └── orders.js
└── services/
├── users.js
└── orders.js
Example:
import express from "express";
const router = express.Router();
function authenticate(req, res, next) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({
error: "Unauthorized"
});
}
next();
}
router.use(authenticate);
router.get("/", (req, res) => {
res.json({
message: "Authenticated users"
});
});
export default router;
Mount it:
app.use("/api/users", userRouter);
Now:
GET /api/users
passes through the router's authentication middleware before reaching the handler. Express supports both application-level and router-level middleware. ([Express.js][1])
4.7 Authentication Middleware
Authentication determines:
Who is making this request?
A simplified JWT middleware:
import jwt from "jsonwebtoken";
export function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
return res.status(401).json({
error: "Missing authentication token"
});
}
const token = header.substring(7);
try {
const payload = jwt.verify(
token,
process.env.JWT_SECRET
);
req.user = payload;
next();
} catch {
return res.status(401).json({
error: "Invalid or expired token"
});
}
}
The pipeline becomes:
Request
|
v
Authorization Header
|
v
Extract Token
|
v
Verify Signature
|
+---- Invalid → 401
|
v
req.user = payload
|
v
next()
|
v
Controller
The middleware creates a controlled boundary between unauthenticated HTTP traffic and authenticated application logic.
4.8 Authorization Middleware
Authentication answers who you are.
Authorization answers what you are allowed to do.
export function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
error: "Authentication required"
});
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: "Forbidden"
});
}
next();
};
}
Use it:
router.delete(
"/users/:id",
authenticate,
requireRole("admin"),
deleteUser
);
The pipeline becomes:
Request
↓
authenticate
↓
requireRole("admin")
↓
deleteUser
4.9 Validation Middleware
Validation should happen before business logic.
function validateCreateUser(req, res, next) {
const { name, email, password } = req.body;
if (
typeof name !== "string" ||
typeof email !== "string" ||
typeof password !== "string"
) {
return res.status(400).json({
error: "Invalid request body"
});
}
if (password.length < 8) {
return res.status(400).json({
error: "Password must contain at least 8 characters"
});
}
next();
}
Then:
router.post(
"/",
authenticate,
validateCreateUser,
createUser
);
This prevents the controller from becoming responsible for every HTTP-level validation rule.
4.10 Error-Handling Middleware
Errors should have a centralized path instead of every controller independently formatting errors.
Express identifies error-handling middleware through its four-argument signature:
(err, req, res, next)
([Express.js][1])
Example:
export function errorHandler(err, req, res, next) {
console.error(err);
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: err.message || "Internal server error"
});
}
Register it after routes:
app.use("/api/users", userRouter);
app.use("/api/orders", orderRouter);
app.use(errorHandler);
A typical structure is:
Request
↓
Middleware
↓
Router
↓
Controller
↓
Service
↓
Error
↓
next(error)
↓
Error Handler
↓
HTTP Response
4.11 Async Middleware
Modern backend applications perform many asynchronous operations:
- Database queries
- HTTP requests
- File operations
- Cache operations
- Message queues
- External APIs
Example:
async function loadUser(req, res, next) {
try {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: "User not found"
});
}
req.userRecord = user;
next();
} catch (error) {
next(error);
}
}
The important pattern is:
await operation
|
+---- success → next()
|
+---- failure → next(error)
In current Express behavior, rejected promises from middleware are propagated to next() automatically in Express 5, while explicitly calling next(error) remains a clear pattern for asynchronous control flow. ([Express.js][2])
4.12 Complete Backend Pipeline
A clean production-oriented structure can look like:
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.json());
app.use((req, res, next) => {
req.requestId = crypto.randomUUID();
console.log({
requestId: req.requestId,
method: req.method,
path: req.originalUrl
});
next();
});
function authenticate(req, res, next) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({
error: "Authentication required"
});
}
req.user = {
id: "user-123",
role: "admin"
};
next();
}
function requireRole(role) {
return (req, res, next) => {
if (req.user?.role !== role) {
return res.status(403).json({
error: "Forbidden"
});
}
next();
};
}
function validateUser(req, res, next) {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({
error: "name and email are required"
});
}
next();
}
app.post(
"/api/users",
authenticate,
requireRole("admin"),
validateUser,
async (req, res, next) => {
try {
const user = {
id: crypto.randomUUID(),
name: req.body.name,
email: req.body.email
};
res.status(201).json(user);
} catch (error) {
next(error);
}
}
);
app.use((err, req, res, next) => {
console.error({
requestId: req.requestId,
error: err.message
});
res.status(500).json({
error: "Internal server error",
requestId: req.requestId
});
});
app.listen(3000, () => {
console.log("Server running on port 3000");
});
The complete execution path is:
POST /api/users
|
v
express.json()
|
v
Request ID Middleware
|
v
Authentication
|
v
Authorization
|
v
Validation
|
v
Route Handler
|
v
Business Logic
|
v
Response
If something fails:
Any Middleware / Controller
|
v
next(error)
|
v
Error Middleware
|
v
HTTP Error Response
4.13 Middleware Composition
Middleware becomes powerful when small functions are composed.
Instead of one enormous function:
function everything(req, res, next) {
// authentication
// authorization
// validation
// logging
// caching
// rate limiting
// business logic
// error handling
}
Prefer:
router.post(
"/orders",
requestLogger,
rateLimiter,
authenticate,
requireRole("customer"),
validateOrder,
createOrder
);
This creates a readable pipeline of responsibilities.
Each middleware should ideally have one clear responsibility.
4.14 Route-Specific vs Global Middleware
Global middleware:
app.use(requestLogger);
is appropriate when almost every request needs the behavior.
Route-specific middleware:
app.post(
"/payments",
authenticate,
requireRole("admin"),
processPayment
);
is appropriate when the behavior only applies to particular endpoints.
A practical structure is:
Global
├── JSON parsing
├── Request ID
├── CORS
├── Security headers
└── General logging
Router
├── Authentication
├── Authorization
├── Validation
└── Resource-specific rules
Controller
└── Business operation
4.15 The Most Important Middleware Rules
Middleware order is execution order.
next() transfers control forward.
next(error) transfers control to error-handling middleware.
Sending a response should normally terminate the current path.
Authentication should occur before protected business logic.
Authorization should occur after authentication.
Validation should happen before expensive business operations.
Error middleware should be centralized.
Middleware should remain focused and composable.
Business logic should not be buried inside generic middleware.
A well-designed request-response pipeline makes the backend easier to secure, test, debug, observe, and maintain. Express's middleware model is explicitly built around this ordered chain of functions that can modify requests and responses, terminate processing, or delegate to subsequent middleware. ([Express.js][1])
Backend Engineering Ebook
Grab the Backend Engineering ebook:
https://codewithdhanian.gumroad.com/l/ungqng

Top comments (0)