Express Middleware: Request Pipeline Deep Dive

Express Middleware: Request Pipeline Deep Dive
Middleware is the most powerful concept in Express. Every feature you use — body parsing, authentication, logging, rate limiting, CORS, error handling — is implemented as middleware. Understanding how the middleware pipeline works is the difference between guessing at your application's behaviour and knowing it with certainty.
This module covers everything: how middleware executes, writing custom middleware for real use cases, composing middleware chains, and the subtle rules that trip up even experienced developers.
This is Module 11 of the Node.js Full‑Stack Developer course.
What is Middleware?
A middleware function is any function with this signature:
function myMiddleware(req, res, next) {
// do something with req or res
next() // pass to the next middleware
}It has three jobs, and does exactly one of them:
- Execute any code — log a request, parse a body, check a token
- Modify req or res — attach a
req.user, set a response header - End the request —
res.json(),res.send(),res.status(401).end() - Call
next()— pass control downstream (ornext(err)to jump to error handler)
If a middleware neither ends the request nor calls next(), the request hangs indefinitely.
The Request Pipeline Visualised
Incoming HTTP Request
│
▼
┌─────────────────────┐
│ Global Middleware │ app.use(logger)
│ (runs every req) │ app.use(helmet())
│ │ app.use(express.json())
└──────────┬──────────┘
│ next()
▼
┌─────────────────────┐
│ Route Middleware │ app.use('/api', rateLimiter)
│ (path-specific) │ app.use('/api', authMiddleware)
└──────────┬──────────┘
│ next()
▼
┌─────────────────────┐
│ Route Handler │ app.get('/api/users', getUsers)
│ │ ← sends response here normally
└──────────┬──────────┘
│ next(err) if error
▼
┌─────────────────────┐
│ 404 Handler │ app.use((req, res) => res.status(404)...)
└──────────┬──────────┘
│ next(err)
▼
┌─────────────────────┐
│ Error Handler │ app.use((err, req, res, next) => ...)
│ (4 parameters) │
└─────────────────────┘
│
▼
HTTP Response sentMiddleware runs top to bottom, in registration order. This order is critical — get it wrong and your auth checks run after your routes execute.
Types of Middleware
1. Application-Level Middleware
Attached to the app instance. Runs on every request (or every request to a path):
const express = require('express')
const app = express()
// Runs on EVERY request
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`)
next()
})
// Runs only on requests to /api/*
app.use('/api', (req, res, next) => {
console.log('API request received')
next()
})2. Router-Level Middleware
Attached to an express.Router() instance — same behaviour as app-level but scoped to the router:
const router = express.Router()
router.use((req, res, next) => {
console.log('Router middleware — runs on all router routes')
next()
})
router.get('/users', getUsers)
router.post('/users', createUser)
module.exports = router3. Route-Level Middleware
Applied to a specific route only:
// authMiddleware only runs for this specific route
app.get('/dashboard', authMiddleware, dashboardHandler)
// Multiple middleware in sequence
app.post('/users',
validateBody(createUserSchema), // runs first
checkDuplicateEmail, // runs second
createUser // route handler last
)4. Error-Handling Middleware
Four parameters — Express identifies this as an error handler by the err first argument:
// Must have exactly 4 parameters to be recognised as error handler
app.use((err, req, res, next) => {
console.error(err.stack)
res.status(err.status || 500).json({ error: err.message })
})5. Built-In Middleware
Express ships three built-in middleware functions:
app.use(express.json()) // parse JSON bodies
app.use(express.urlencoded({ extended: true })) // parse form data
app.use(express.static('public')) // serve static filesWriting Custom Middleware
Request Logger
// middleware/logger.js
const logger = (req, res, next) => {
const start = Date.now()
// Intercept res.end to capture status code after response is sent
const originalEnd = res.end.bind(res)
res.end = function (...args) {
const duration = Date.now() - start
console.log(
`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} ` +
`${res.statusCode} ${duration}ms`
)
return originalEnd(...args)
}
next()
}
module.exports = logger// Usage
const logger = require('./middleware/logger')
app.use(logger)Request ID Middleware
Attach a unique ID to every request for tracing through logs:
// middleware/requestId.js
const { randomUUID } = require('crypto')
const requestId = (req, res, next) => {
req.id = req.headers['x-request-id'] || randomUUID()
res.set('X-Request-Id', req.id)
next()
}
module.exports = requestIdCORS Middleware
// middleware/cors.js
const cors = (allowedOrigins = []) => (req, res, next) => {
const origin = req.headers.origin
if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) {
res.set('Access-Control-Allow-Origin', origin || '*')
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-Id')
res.set('Access-Control-Allow-Credentials', 'true')
}
// Handle preflight OPTIONS request
if (req.method === 'OPTIONS') {
return res.status(204).end()
}
next()
}
module.exports = corsOr use the battle-tested cors npm package:
npm install corsconst cors = require('cors')
app.use(cors({
origin: ['https://myapp.com', 'http://localhost:3000'],
credentials: true,
}))Authentication Guard
// middleware/auth.js
const jwt = require('jsonwebtoken')
const JWT_SECRET = process.env.JWT_SECRET
const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: { code: 'UNAUTHORISED', message: 'No token provided' },
})
}
const token = authHeader.split(' ')[1]
try {
const decoded = jwt.verify(token, JWT_SECRET)
req.user = decoded // attach decoded payload to req
next()
} catch (err) {
const message = err.name === 'TokenExpiredError'
? 'Token has expired'
: 'Invalid token'
return res.status(401).json({
success: false,
error: { code: 'UNAUTHORISED', message },
})
}
}
module.exports = authenticate// Protect all routes under /api/protected
const authenticate = require('./middleware/auth')
app.use('/api/protected', authenticate)
// Or protect specific routes
app.get('/api/profile', authenticate, getProfile)
app.put('/api/profile', authenticate, updateProfile)Role-Based Access Control
// middleware/authorize.js
const authorize = (...allowedRoles) => (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' })
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
success: false,
error: {
code: 'FORBIDDEN',
message: `Role '${req.user.role}' is not allowed to access this resource`,
},
})
}
next()
}
module.exports = authorizeconst authenticate = require('./middleware/auth')
const authorize = require('./middleware/authorize')
// Must be logged in AND be an admin
app.delete('/api/users/:id', authenticate, authorize('admin'), deleteUser)
// Must be logged in AND be either admin or moderator
app.patch('/api/posts/:id/hide', authenticate, authorize('admin', 'moderator'), hidePost)Request Body Validation Middleware
// middleware/validate.js
const Joi = require('joi')
const validate = (schema, source = 'body') => (req, res, next) => {
const target = source === 'body' ? req.body
: source === 'query' ? req.query
: req.params
const { error, value } = schema.validate(target, {
abortEarly: false, // collect ALL errors, not just the first
stripUnknown: true, // remove unknown fields silently
convert: true, // coerce types (string "42" → number 42)
})
if (error) {
const details = error.details.map(d => ({
field: d.path.join('.'),
message: d.message.replace(/['"]/g, ''),
}))
return res.status(422).json({
success: false,
data: null,
error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details },
})
}
// Replace with sanitised value
if (source === 'body') req.body = value
else if (source === 'query') req.query = value
else req.params = value
next()
}
module.exports = validateconst validate = require('./middleware/validate')
const { createUserSchema, updateUserSchema } = require('./schemas/userSchemas')
router.post('/', validate(createUserSchema), createUser)
router.patch('/:id', validate(updateUserSchema), updateUser)
router.get('/', validate(listQuerySchema, 'query'), listUsers)Middleware Factories (Higher-Order Middleware)
A middleware factory is a function that returns a middleware function. It lets you configure middleware at registration time:
// A configurable timeout middleware
const timeout = (ms) => (req, res, next) => {
const timer = setTimeout(() => {
if (!res.headersSent) {
res.status(503).json({ error: `Request timed out after ${ms}ms` })
}
}, ms)
// Clear the timer when the response finishes
res.on('finish', () => clearTimeout(timer))
next()
}
// Usage — 5 second timeout on all API routes
app.use('/api', timeout(5000))
// Stricter 2 second timeout on search (expensive operation)
app.get('/api/search', timeout(2000), searchHandler)Async Middleware
Middleware can be async. But in Express 4, unhandled rejections in middleware are not automatically forwarded to the error handler — you must catch them:
// ❌ Express 4 won't catch this rejection
app.use(async (req, res, next) => {
req.user = await User.findById(req.headers['x-user-id']) // throws if DB is down
next()
})
// ✅ Always wrap async middleware
app.use(async (req, res, next) => {
try {
req.user = await User.findById(req.headers['x-user-id'])
next()
} catch (err) {
next(err)
}
})Or create a asyncMiddleware wrapper to keep it clean:
const asyncMiddleware = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next)
app.use(asyncMiddleware(async (req, res, next) => {
req.user = await User.findById(req.headers['x-user-id'])
next()
}))The Error Handler in Depth
// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
// Log the error (use a proper logger in production)
console.error({
message: err.message,
stack: err.stack,
requestId: req.id,
method: req.method,
path: req.path,
})
// Mongoose validation error
if (err.name === 'ValidationError') {
const details = Object.values(err.errors).map(e => ({
field: e.path,
message: e.message,
}))
return res.status(422).json({
success: false, data: null,
error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details },
})
}
// Mongoose duplicate key error
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0]
return res.status(409).json({
success: false, data: null,
error: { code: 'CONFLICT', message: `${field} already exists` },
})
}
// JWT errors
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
return res.status(401).json({
success: false, data: null,
error: { code: 'UNAUTHORISED', message: 'Invalid or expired token' },
})
}
// Generic / unhandled error
const status = err.status || err.statusCode || 500
return res.status(status).json({
success: false, data: null,
error: {
code: err.code || 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred'
: err.message,
},
})
}
module.exports = errorHandlerRecommended Middleware Stack Order
// src/app.js — correct middleware registration order
const express = require('express')
const helmet = require('helmet')
const cors = require('cors')
const rateLimit = require('express-rate-limit')
const requestId = require('./middleware/requestId')
const logger = require('./middleware/logger')
const errorHandler = require('./middleware/errorHandler')
const v1Routes = require('./routes/v1')
const app = express()
// 1. Security headers (first — protect before doing anything)
app.use(helmet())
// 2. CORS (before body parsing)
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }))
// 3. Request ID (before logging so logger can include it)
app.use(requestId)
// 4. Request logger
app.use(logger)
// 5. Body parsers
app.use(express.json({ limit: '1mb' }))
app.use(express.urlencoded({ extended: true, limit: '1mb' }))
// 6. Rate limiting
app.use('/api', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }))
// 7. Routes (authentication lives inside route files, not here)
app.get('/health', (req, res) => res.json({ status: 'ok' }))
app.use('/api/v1', v1Routes)
// 8. 404 handler (after all routes)
app.use((req, res) => {
res.status(404).json({
success: false, data: null,
error: { code: 'NOT_FOUND', message: `${req.method} ${req.path} not found` },
})
})
// 9. Error handler (always last)
app.use(errorHandler)
module.exports = appNode.js Full‑Stack Course — Module 11 of 32
You now fully understand the Express middleware pipeline. Continue to Module 12 to master handling JSON, form data, and file uploads in your API.
Summary
Middleware is the engine of every Express application. The rules to live by:
- Middleware runs in registration order — get it wrong and auth runs after routes
- Always call
next()or send a response — never both, never neither next(err)skips all regular middleware and jumps to the error handler- Error handlers need exactly 4 parameters:
(err, req, res, next) - Use middleware factories to create configurable, reusable middleware
- Always wrap
asyncmiddleware in try/catch and callnext(err)on failure - Recommended order: security → CORS → requestId → logger → body parsers → rate limit → routes → 404 → error handler
Continue to Module 12: Handling JSON, Form Data, and File Uploads →
Content powered by SearchFit.ai — for automated content at scale, visit https://searchfit.ai
