Middleware Pipeline Pattern

Type: Software Reference Confidence: 0.90 Sources: 7 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

FrameworkMiddleware SignatureExecution OrderError HandlingContext Passing
Express (Node.js)(req, res, next) => {}Registration order4-arg (err, req, res, next)req.customProp mutation
Koa (Node.js)async (ctx, next) => {}Onion model (downstream then upstream)try/catch around await next()ctx.state object
Fastify (Node.js)(req, reply, done) => {} or hooksHook lifecycle orderonError hookreq.customProp via decorators
Django (Python)class with __call__(self, request)MIDDLEWARE list orderprocess_exception() methodrequest.META / custom attrs
FastAPI (Python)@app.middleware("http") or ASGI classRegistration ordertry/except in middlewarerequest.state
ASP.NET Core (C#)RequestDelegate via Use() / Run()Configure() pipeline orderUseExceptionHandler()HttpContext.Items dict
Go net/httpfunc(http.Handler) http.HandlerWrapping order (outermost first)recover() in defercontext.WithValue()
Gin (Go)func(c *gin.Context)Use() registration orderc.AbortWithError()c.Set() / c.Get()
Fiber (Go)func(c *fiber.Ctx) errorUse() registration orderError return + ErrorHandlerc.Locals()

Decision Tree

START
|-- Need cross-cutting concerns (logging, auth, CORS, rate-limit)?
|   |-- YES --> Use middleware pipeline (this pattern)
|   +-- NO --> Standard route handlers are sufficient
|
|-- Using Node.js?
|   |-- Need async/await with backpressure? --> Koa (onion model)
|   |-- Need maximum performance? --> Fastify (hook lifecycle)
|   +-- General purpose? --> Express (classic next() chain)
|
|-- Using Python?
|   |-- Async with type hints? --> FastAPI ASGI middleware
|   +-- Traditional sync? --> Django middleware classes
|
|-- Using Go?
|   |-- Standard library only? --> net/http Handler wrapping
|   +-- Need routing + middleware? --> Gin or Fiber
|
|-- Using C#?
|   +-- ASP.NET Core --> RequestDelegate pipeline with Use/Map/Run
|
+-- DEFAULT --> Implement the handler-wrapper pattern:
     func middleware(next Handler) Handler

Step-by-Step Guide

1. Define the middleware signature

Choose the appropriate signature for your framework. The core idea is always the same: accept a handler (or next function), do work before/after calling it. [src1]

// Express: (req, res, next) => void
const myMiddleware = (req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next(); // pass to next middleware
};

Verify: Add console.log('middleware hit') and confirm it prints on every request.

2. Register middleware in the correct order

Order determines behavior. Auth must come before protected routes. Logging typically comes first. [src2]

const express = require('express');
const app = express();

app.use(requestLogger);       // 1. Logging (runs first)
app.use(cors());               // 2. CORS headers
app.use(express.json());       // 3. Body parsing
app.use(authMiddleware);       // 4. Authentication
app.use('/api', apiRouter);    // 5. Routes (protected)
app.use(errorHandler);         // 6. Error handler (last, 4 args)

Verify: Send an unauthenticated request -- it should be rejected at step 4.

3. Implement short-circuiting for guard middleware

Guard middleware (auth, rate-limit) should NOT call next() when the check fails. [src1]

const authMiddleware = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  try {
    req.user = verifyToken(token);
    next();
  } catch (err) {
    return res.status(403).json({ error: 'Invalid token' });
  }
};

Verify: curl -H "Authorization: Bearer invalid" /api/protected returns 403.

4. Handle errors in the pipeline

Each framework has its own error propagation model. Express uses a special 4-argument middleware. [src5]

// Express error-handling middleware (MUST have 4 parameters)
const errorHandler = (err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: err.message || 'Internal Server Error'
  });
};
app.use(errorHandler);

Verify: Throw an error in a route handler and confirm the error middleware catches it.

5. Pass data between middleware via request context

Never use module-level variables for per-request data. Use the request-scoped context object. [src3]

app.use((req, res, next) => {
  req.requestId = crypto.randomUUID();
  req.startTime = Date.now();
  next();
});

app.get('/api/data', (req, res) => {
  res.json({ requestId: req.requestId });
});

Verify: Two concurrent requests should have different requestId values.

Code Examples

Node.js/Express: Complete Middleware Pipeline

// Input:  HTTP request
// Output: HTTP response processed through logging, auth, and timing middleware

const express = require('express');
const app = express();

// Timing middleware
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    console.log(`${req.method} ${req.path} ${res.statusCode} ${Date.now() - start}ms`);
  });
  next();
});

// Auth middleware
app.use('/api', (req, res, next) => {
  const key = req.headers['x-api-key'];
  if (key !== process.env.API_KEY) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next();
});

app.get('/api/data', (req, res) => {
  res.json({ message: 'protected data' });
});

// Error handler (always last, always 4 args)
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message });
});

Python/FastAPI: ASGI Middleware

# Input:  ASGI HTTP request
# Output: Response with timing header and request ID

import time, uuid
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware

app = FastAPI()

class TimingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        start = time.perf_counter()
        request.state.request_id = str(uuid.uuid4())
        response = await call_next(request)
        duration = time.perf_counter() - start
        response.headers["X-Process-Time"] = f"{duration:.4f}"
        response.headers["X-Request-ID"] = request.state.request_id
        return response

app.add_middleware(TimingMiddleware)

Go: Custom Middleware Chain (net/http)

// Input:  http.Handler (the next handler in the chain)
// Output: http.Handler (wrapped handler with added behavior)

package main

import (
    "context"; "log"; "net/http"; "time"
)

type ctxKey string

func withLogging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func withAuth(apiKey string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if r.Header.Get("X-API-Key") != apiKey {
                http.Error(w, "Unauthorized", http.StatusUnauthorized)
                return
            }
            ctx := context.WithValue(r.Context(), ctxKey("user"), "authenticated")
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
    for i := len(mw) - 1; i >= 0; i-- { h = mw[i](h) }
    return h
}

Anti-Patterns

Wrong: Auth middleware registered after the route

// BAD -- auth middleware registered AFTER the route
app.get('/admin/users', getUsers);     // unprotected!
app.use(authMiddleware);                // too late

Correct: Guard middleware before protected routes

// GOOD -- auth registered BEFORE the routes it protects
app.use('/admin', authMiddleware);      // runs first
app.get('/admin/users', getUsers);      // protected

Wrong: God middleware with mixed concerns

// BAD -- one middleware handles auth, logging, CORS, rate-limit
app.use((req, res, next) => {
  logRequest(req);
  if (!checkAuth(req)) return res.status(401).end();
  res.setHeader('Access-Control-Allow-Origin', '*');
  if (isRateLimited(req.ip)) return res.status(429).end();
  validateBody(req.body);
  next();
});

Correct: Single-responsibility middleware

// GOOD -- each middleware does one thing
app.use(requestLogger);
app.use(cors({ origin: ALLOWED_ORIGINS }));
app.use(rateLimiter({ windowMs: 60000, max: 100 }));
app.use(authenticate);
app.use(validateRequest);

Wrong: Mutating shared module-level state

// BAD -- shared mutable state across all requests
let currentUser = null;  // module-level -- race condition!
app.use((req, res, next) => {
  currentUser = decodeToken(req.headers.authorization);
  next();
});

Correct: Request-scoped context

// GOOD -- data attached to the request object (per-request scope)
app.use((req, res, next) => {
  req.user = decodeToken(req.headers.authorization);
  next();
});

Common Pitfalls

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Adding cross-cutting concerns (logging, auth, CORS, rate-limiting) to HTTP handlersProcessing asynchronous event streamsMessage queue / pub-sub pattern
Need to compose reusable request/response transformationsSingle handler with no shared concernsDirect route handler
Building plugin systems where third parties extend request processingHeavy data transformation pipelines (ETL)Pipe-and-filter or streaming architecture
Need clean separation of concerns in web application request processingSimple scripts or CLIs with no request/response modelFunction composition or decorator pattern
Framework provides built-in middleware supportPerformance-critical hot path where function call overhead mattersInlined handler logic

Important Caveats

Related Units