Node.jsExpressBackendFull-Stack

Express Middleware: Request Pipeline Deep Dive

TT
TopicTrick Team
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:

javascript
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:

  1. Execute any code — log a request, parse a body, check a token
  2. Modify req or res — attach a req.user, set a response header
  3. End the requestres.json(), res.send(), res.status(401).end()
  4. Call next() — pass control downstream (or next(err) to jump to error handler)

If a middleware neither ends the request nor calls next(), the request hangs indefinitely.


The Request Pipeline Visualised

text
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 sent

Middleware 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):

javascript
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:

javascript
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 = router

3. Route-Level Middleware

Applied to a specific route only:

javascript
// 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:

javascript
// 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:

javascript
app.use(express.json())                        // parse JSON bodies
app.use(express.urlencoded({ extended: true })) // parse form data
app.use(express.static('public'))              // serve static files

Writing Custom Middleware

Request Logger

javascript
// 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
javascript
// Usage
const logger = require('./middleware/logger')
app.use(logger)

Request ID Middleware

Attach a unique ID to every request for tracing through logs:

javascript
// 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 = requestId

CORS Middleware

javascript
// 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 = cors

Or use the battle-tested cors npm package:

bash
npm install cors
javascript
const cors = require('cors')
app.use(cors({
    origin: ['https://myapp.com', 'http://localhost:3000'],
    credentials: true,
}))

Authentication Guard

javascript
// 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
javascript
// 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

javascript
// 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 = authorize
javascript
const 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

javascript
// 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 = validate
javascript
const 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:

javascript
// 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:

javascript
// ❌ 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:

javascript
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

javascript
// 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 = errorHandler

Recommended Middleware Stack Order

javascript
// 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 = app

Node.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 async middleware in try/catch and call next(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