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)

Table of Contents

  1. Architectural Overview & Core Schema
  2. Deep Dive & Implementation
  3. Anti-Patterns & Common Pitfalls
  4. Independent Challenge
  5. Consolidation & Key Invariants
  6. Next Steps

1. Architectural Overview & Core Schema

A middleware is a function that wraps a request handler. It can:

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:

  1. Tracks request counts per IP using an in-memory token bucket algorithm
  2. Returns 429 Too Many Requests with a Retry-After header when the limit is exceeded
  3. Applies only to POST and PUT requests (GET is unlimited)
  4. Logs all rate-limited requests with IP and retry count
  5. Uses a Map<string, { tokens: number; lastRefill: number }> as the in-memory store
  6. 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

6. Next Steps


Change Log

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse