next() callback pattern -- each middleware calls next() to pass control to the next handler, or short-circuits by sending a response directly.next() (or equivalent) or the request will hang or time out| Framework | Middleware Signature | Execution Order | Error Handling | Context Passing |
|---|---|---|---|---|
| Express (Node.js) | (req, res, next) => {} | Registration order | 4-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 hooks | Hook lifecycle order | onError hook | req.customProp via decorators |
| Django (Python) | class with __call__(self, request) | MIDDLEWARE list order | process_exception() method | request.META / custom attrs |
| FastAPI (Python) | @app.middleware("http") or ASGI class | Registration order | try/except in middleware | request.state |
| ASP.NET Core (C#) | RequestDelegate via Use() / Run() | Configure() pipeline order | UseExceptionHandler() | HttpContext.Items dict |
| Go net/http | func(http.Handler) http.Handler | Wrapping order (outermost first) | recover() in defer | context.WithValue() |
| Gin (Go) | func(c *gin.Context) | Use() registration order | c.AbortWithError() | c.Set() / c.Get() |
| Fiber (Go) | func(c *fiber.Ctx) error | Use() registration order | Error return + ErrorHandler | c.Locals() |
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
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.
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.
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.
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.
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.
// 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 });
});
# 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)
// 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
}
// BAD -- auth middleware registered AFTER the route
app.get('/admin/users', getUsers); // unprotected!
app.use(authMiddleware); // too late
// GOOD -- auth registered BEFORE the routes it protects
app.use('/admin', authMiddleware); // runs first
app.get('/admin/users', getUsers); // protected
// 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();
});
// 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);
// 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();
});
// GOOD -- data attached to the request object (per-request scope)
app.use((req, res, next) => {
req.user = decodeToken(req.headers.authorization);
next();
});
next(): Request hangs until client timeout. Every non-terminal middleware must call next(). Fix: lint rule or middleware wrapper that warns on missing next() call. [src1]next() after sending a response: Causes "headers already sent" errors in Express or double-write panics in Go. Fix: always return after res.send() / res.json(). [src5]next(err) (Express) or re-raise (Python). [src2]await next(): In Koa and FastAPI, forgetting await on next() / call_next() means post-processing runs before downstream completes. Fix: always await next() in async middleware. [src4]app.use('/api', middleware). [src1]await next() runs in reverse order (upstream). Fix: understand that Koa middleware wraps like nested function calls. [src5]| Use When | Don't Use When | Use Instead |
|---|---|---|
| Adding cross-cutting concerns (logging, auth, CORS, rate-limiting) to HTTP handlers | Processing asynchronous event streams | Message queue / pub-sub pattern |
| Need to compose reusable request/response transformations | Single handler with no shared concerns | Direct route handler |
| Building plugin systems where third parties extend request processing | Heavy data transformation pipelines (ETL) | Pipe-and-filter or streaming architecture |
| Need clean separation of concerns in web application request processing | Simple scripts or CLIs with no request/response model | Function composition or decorator pattern |
| Framework provides built-in middleware support | Performance-critical hot path where function call overhead matters | Inlined handler logic |
await next() in Koa runs after downstream handlers complete, creating a symmetrical wrap.Chain(handler, A, B) means A executes first (outermost), then B, then handler.