Middleware Patterns — Composable Request Handlers
Status: Active | Last Updated: 2026-08-29 Category: Backend — Middleware Prerequisites: Routing basics Tags: middleware, chain, auth, cors, logging, error-handling Estimated Time: 4-5 hours (Self-paced, includes lab time)
Summary
Middleware is a function that runs before or after your route handler. It can inspect the request, modify it, short-circuit the response, or pass through to the next handler. Authentication, logging, CORS, and error handling are all common middleware. Understanding this pattern lets you build composable, reusable request processing pipelines.
What You'll Learn (Core Competencies)
- Implement a middleware wrapper function that adds behavior around a handler
- Compose multiple middleware functions into a chain with controlled execution order
- Build four production-ready middleware: CORS, logging, authentication, and error handling
- Recognize when middleware is the wrong abstraction and use plain functions instead
- Propagate typed context (e.g., authenticated user) through middleware chains safely
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
1. Architectural Overview & Core Schema
A middleware is a function that wraps a request handler. It can:
- Inspect the request (
request.headers,request.url) - Short-circuit the response (return early without calling the handler)
- Augment the request with additional context for downstream handlers
- Run after the handler to log or transform the response
type Middleware = (
request: Request,
next: () => Promise<Response>
) => Promise<Response>;
The next() call delegates to the next middleware or the final handler. The middleware pattern is the same as Express, Koa, Hono, and most modern frameworks. Once you understand the shape, the syntax differs but the mental model is identical: each function either calls next() to continue or returns a response to short-circuit.
In Bun, build middleware on top of Bun.serve() by wrapping the fetch handler:
type Handler = (request: Request) => Promise<Response>;
function withMiddleware(handler: Handler, ...middlewares: Array<(h: Handler) => Handler>): Handler {
return middlewares.reduceRight((acc, mw) => mw(acc), handler);
}
Each middleware wraps the handler, so the outermost middleware runs first on the way in and last on the way out.
2. Deep Dive & Implementation
Composing Middleware
The wrapping style composes cleanly: pass withAuth(withLogging(withCORS(handler))) and the order is obvious.
function withAuth(handler: Handler): Handler {
return async (request) => {
const token = request.headers.get("Authorization");
if (!token) return new Response("Unauthorized", { status: 401 });
return handler(request);
};
}
function withLogging(handler: Handler): Handler {
return async (request) => {
const start = Date.now();
const response = await handler(request);
const duration = Date.now() - start;
console.log(`${request.method} ${new URL(request.url).pathname} ${response.status} (${duration}ms)`);
return response;
};
}
function withCORS(handler: Handler): Handler {
return async (request) => {
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
});
}
const response = await handler(request);
response.headers.set("Access-Control-Allow-Origin", "*");
return response;
};
}
// Compose: outermost runs first
const secured = withCORS(withLogging(withAuth(routeHandler)));
Order matters — CORS first ensures preflight responses work before any other check. Auth before logging means failed auth attempts are still logged.
CORS Middleware
Adds Access-Control-Allow-Origin headers for browser-based clients. Required when frontend and backend live on different origins.
function enableCORS(handler: Handler): Handler {
return async (request) => {
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
});
}
const response = await handler(request);
response.headers.set("Access-Control-Allow-Origin", "*");
return response;
};
}
Logging Middleware
Records each request method, path, status, and duration. Essential for debugging and observability.
function logRequests(handler: Handler): Handler {
return async (request) => {
const start = Date.now();
const response = await handler(request);
const duration = Date.now() - start;
console.log(`${request.method} ${new URL(request.url).pathname} ${response.status} (${duration}ms)`);
return response;
};
}
Authentication Middleware
Verifies a Bearer token before allowing access.
function requireAuth(handler: Handler): Handler {
return async (request) => {
const auth = request.headers.get("Authorization");
if (!auth?.startsWith("Bearer ")) {
return new Response("Unauthorized", { status: 401 });
}
const token = auth.slice(7);
const user = await verifyToken(token);
if (!user) return new Response("Forbidden", { status: 403 });
return handler(request);
};
}
Error Handling Middleware
Centralizes error handling so route logic stays clean. A top-level error middleware catches uncaught exceptions and returns a 500 response.
function withErrorHandling(handler: Handler): Handler {
return async (request) => {
try {
return await handler(request);
} catch (err) {
console.error("Unhandled error:", err);
return Response.json(
{ error: "Internal Server Error" },
{ status: 500 }
);
}
};
}
For specific error types, throw custom error classes and catch them explicitly:
class HTTPError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
function withErrorHandling(handler: Handler): Handler {
return async (request) => {
try {
return await handler(request);
} catch (err) {
if (err instanceof HTTPError) {
return Response.json({ error: err.message }, { status: err.status });
}
console.error("Unhandled error:", err);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
};
}
Always log errors with stack traces in development. In production, log to a structured logger and avoid leaking stack details to clients.
Guided Checkpoint
Verify the middleware chain with a series of requests:
# Start: bun run server.ts
# Unauthenticated request — returns 401
curl -s -w "\n%{http_code}" http://localhost:3000/protected
# Authenticated request — returns 200 with response body
curl -s -H "Authorization: Bearer valid-token-123" \
http://localhost:3000/protected | jq .
# Preflight OPTIONS — returns 200 with CORS headers (no body)
curl -s -X OPTIONS http://localhost:3000/protected \
-H "Origin: http://localhost:3001" | head -20
# CORS request from different origin — has Access-Control-Allow-Origin header
curl -s -I http://localhost:3000/protected \
-H "Origin: http://localhost:3001" | grep -i access-control
# Invalid token — returns 403
curl -s -w "\n%{http_code}" -H "Authorization: Bearer bad-token" \
http://localhost:3000/protected
# Logged: GET /protected 401 (12ms) — auth failure logged with duration
# Logged: GET /protected 200 (8ms) — successful request logged
All requests should show a duration in the logs. Preflight OPTIONS requests should return immediately without hitting the auth middleware. Invalid tokens should return 403, not 401.
3. Anti-Patterns & Common Pitfalls
Middleware that mutates requests in ways only some handlers expect. If a middleware adds a user field to a request but only authenticated routes use it, the type system loses track of when user is present. Prefer passing context explicitly through function parameters rather than mutating the request object.
Middleware that swallows errors. A catch block that returns 200 on error hides failures from clients and debugging tools. Always return a 500 (or appropriate error status) in error handlers, and always log the error.
Logging raw Authorization headers. Logging the full Authorization header value can leak tokens or credentials to log files. Log the header name only, or hash the token value before logging.
Running auth after CORS for non-simple requests. Browser preflight OPTIONS requests do not include credentials (Authorization headers). If CORS middleware calls auth middleware, preflight requests fail because the auth check cannot see the token. Always check request.method === "OPTIONS" before auth in CORS middleware.
Too many middleware layers. A chain of 8+ middleware makes debugging hard — errors get swallowed two layers deep and request context becomes unpredictable. If the chain is long, refactor into smaller, more focused services.
Middleware that changes the response after the handler has already returned. Modifying a Response after it has been sent can cause race conditions. Middleware should only wrap the handler call, not touch the response after awaiting it.
Not handling the OPTIONS method in CORS middleware. If CORS middleware doesn't return early for OPTIONS, the request falls through to the next middleware, potentially triggering auth checks that fail because the browser didn't send credentials on the preflight.
4. Independent Challenge
Design and implement a rate-limiting middleware chain that:
- Tracks request counts per IP using an in-memory token bucket algorithm
- Returns
429 Too Many Requestswith aRetry-Afterheader when the limit is exceeded - Applies only to
POSTandPUTrequests (GET is unlimited) - Logs all rate-limited requests with IP and retry count
- Uses a
Map<string, { tokens: number; lastRefill: number }>as the in-memory store - Composes with the existing CORS, logging, and auth middleware from this lesson
Integrate it into a server with at least two routes: POST /submit (rate-limited) and GET /status (unlimited). Verify with curl that the rate limit kicks in after the configured number of requests.
5. Consolidation & Key Invariants
- Check
request.method === "OPTIONS"first in CORS middleware before any other checks; preflight requests do not include credentials - Always return a proper error status (
400,401,403,500) from error handlers; never swallow errors silently - Middleware order matters: CORS first, then logging, then auth, then routes
- Pass typed context (authenticated user, request ID) through function parameters, not by mutating the
Requestobject - Log errors with full stack traces in development; log structured error metadata in production without exposing internals to clients
- Return
429with aRetry-Afterheader when rate limiting; clients need to know when to retry - Keep middleware chains short (3-5 layers); deep chains hide bugs and hurt observability
6. Next Steps
- Read
kb/backend/routing-patterns.mdto understand how middleware interacts with route groups and versioning. - Read
kb/backend/bun-http.mdfor streaming and WebSocket patterns that need custom middleware. - Read
kb/backend/authentication-patterns.mdfor deeper JWT verification, refresh tokens, and OAuth flows. - Read
kb/backend/production-backend.mdfor distributed rate limiting with Redis and structured logging. - Build a small API with auth, logging, and error-handling middleware, then deploy it with Docker (
kb/containers/docker-concepts.md).
Change Log
- 2026-08-29: Initial scaffold
- 2026-08-29: Hydrated to production depth — expanded all sections with code examples, added Common Middleware Types, Error Handling, anti-patterns, Next Steps, and Change Log sections
- 2026-08-29: Migrated to 6-section canonical template (2026-08-29)