Skip to content
StackPractices
intermediate By Mathias Paulenko

Express.js Middleware Composition Patterns

Build maintainable Express applications using middleware composition patterns for authentication, validation, error handling, request context propagation, and async route wrappers

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Express.js Middleware Composition Patterns

Express middleware is the backbone of Node.js API architecture, but deeply nested or duplicated middleware chains quickly become unmaintainable. This approach handles composition patterns for authentication, validation, error handling, request context propagation, and async route wrappers that keep route handlers clean and testable.

When to Use This

  • Express routes accumulate repetitive middleware (auth, logging, validation) copy-pasted everywhere
  • Async route handlers throw unhandled promise rejections that crash the server. See Error Handling for patterns.
  • You need request-scoped context (user, trace ID) accessible throughout the call stack

Solution

1. Async Route Wrapper

// middleware/asyncHandler.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';

type AsyncRequestHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;

function asyncHandler(fn: AsyncRequestHandler): RequestHandler {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

// Usage — no try/catch needed
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await userService.findById(req.params.id);
  res.json(user);
}));

2. Composable Middleware Factory

// middleware/compose.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';

type Middleware = RequestHandler | [RequestHandler, ...RequestHandler[]];

function compose(...middlewares: Middleware[]): RequestHandler[] {
  return middlewares.flatMap((m) => Array.isArray(m) ? m : [m]);
}

// Usage — declarative middleware stacks
const authenticated = compose(verifyToken, requireActiveUser);
const validated = (schema: ZodSchema) => compose(validateBody(schema));

app.post('/posts', ...compose(authenticated, validated(createPostSchema)), asyncHandler(createPost));
app.patch('/posts/:id', ...compose(authenticated, validated(updatePostSchema)), asyncHandler(updatePost));

3. Request Context Propagation

// middleware/context.ts
import { AsyncLocalStorage } from 'async_hooks';
import { Request, Response, NextFunction } from 'express';

interface RequestContext {
  traceId: string;
  user?: { id: string; role: string };
  startTime: number;
}

const asyncStorage = new AsyncLocalStorage<RequestContext>();

function contextMiddleware(req: Request, res: Response, next: NextFunction): void {
  const context: RequestContext = {
    traceId: req.headers['x-trace-id'] as string || crypto.randomUUID(),
    startTime: Date.now(),
  };

  asyncStorage.run(context, () => {
    res.setHeader('X-Trace-Id', context.traceId);
    next();
  });
}

// Access context anywhere in the async call stack
function getContext(): RequestContext | undefined {
  return asyncStorage.getStore();
}

// Usage in services
class UserService {
  async findById(id: string): Promise<User> {
    const ctx = getContext();
    logger.info('Fetching user', { traceId: ctx?.traceId, userId: id });
    // ...
  }
}

4. Unified Error Handler

// middleware/errorHandler.ts
import { Request, Response, NextFunction, ErrorRequestHandler } from 'express';

class AppError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code?: string
  ) {
    super(message);
    this.name = 'AppError';
  }
}

const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
  const ctx = getContext();

  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      error: err.message,
      code: err.code,
      traceId: ctx?.traceId,
    });
    return;
  }

  if (err.name === 'ValidationError') {
    res.status(400).json({
      error: 'Validation failed',
      details: err.errors,
      traceId: ctx?.traceId,
    });
    return;
  }

  logger.error('Unhandled error', { traceId: ctx?.traceId, error: err });
  res.status(500).json({
    error: 'Internal server error',
    traceId: ctx?.traceId,
  });
};

app.use(errorHandler);

5. Validation Middleware with Zod

// middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';

function validateBody(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        error: 'Validation failed',
        issues: result.error.issues,
      });
      return;
    }
    req.body = result.data;
    next();
  };
}

function validateParams(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req.params);
    if (!result.success) {
      res.status(400).json({ error: 'Invalid parameters', issues: result.error.issues });
      return;
    }
    req.params = result.data;
    next();
  };
}

How It Works

  • Async wrappers catch rejected promises and forward them to Express error handlers
  • Composition flattens nested middleware arrays into clean, reusable stacks
  • AsyncLocalStorage creates implicit request context without manual propagation through every function signature
  • Typed validation transforms and narrows request data at the boundary before route handlers execute

Production Considerations

  • Register error handlers last in middleware stack (after all routes)
  • Do not call next() after sending a response; it causes “headers already sent” errors
  • Use res.on('finish') for logging middleware to capture the actual response status

Common Mistakes

  • Calling next() inside async middleware without awaiting, causing race conditions
  • Forgetting to call next() in synchronous middleware, hanging requests indefinitely
  • Throwing strings instead of Error objects, losing stack traces

Best Practices

  • Order matters: register middleware in the correct sequence — logging first, then authentication, then authorization, then rate limiting, then business logic. A misordered stack can allow unauthenticated requests to hit expensive endpoints.
  • Keep middleware thin: each middleware should do one thing. Avoid combining auth, logging, and validation in a single function. Thin middleware is easier to test, reuse, and debug.
  • Use express.Router() for modular stacks: mount routers with different middleware stacks for different route groups. API routes get auth middleware; public routes get only logging and CORS.
  • Always handle async errors: wrap async middleware in a try/catch and pass errors to next(err). Use express-async-errors or a wrapper function to avoid manual try/catch in every middleware.
  • Set req.locals for shared data: pass data between middleware using req.locals instead of mutating req directly. This is the Express convention and works with most third-party middleware.

Production Checklist

  • Middleware order is: logging → CORS → auth → rate limiting → routes → error handler
  • Error-handling middleware is registered last (4-arg function signature)
  • Async middleware uses error wrapper or express-async-errors package
  • Sensitive request headers are stripped before logging
  • CORS middleware is configured with explicit origin allowlist, not *
  • Body parser has size limits configured (e.g., 1MB for JSON, 10MB for multipart)
  • Helmet middleware is enabled for security headers
  • Request ID is generated and attached to req.locals for tracing
  • Health check endpoint (/health) bypasses auth middleware
  • Graceful shutdown drains in-flight requests before closing

Scaling Considerations

  • Middleware overhead at scale: each middleware adds 0.1-1ms per request. With 10 middleware functions, that’s 1-10ms of overhead before business logic. Profile middleware with express-status-monitor or custom timing middleware to identify bottlenecks.
  • Memory leaks in long-running processes: middleware that accumulates state (caches, connection pools) can leak memory over days/weeks. Monitor heap usage and restart workers periodically with PM2 cluster mode or Kubernetes rolling restarts.
  • Horizontal scaling: Express middleware runs per-instance. Stateful middleware (sessions, rate limiting) needs shared storage (Redis, Memcached) when scaling to multiple instances. Stateless middleware (logging, CORS) works without changes.

When Not to Use This Approach

  • High-performance APIs (>50K req/s): Express adds overhead from middleware chain execution and JavaScript runtime. For extreme throughput, use Fastify (2-3x faster), Go with Gin/Fiber, or Rust with Actix.
  • Serverless functions: Express middleware chains don’t cold-start efficiently on Lambda. Use framework-native handlers (AWS Lambda handler, Vercel edge functions) for serverless deployments.
  • Simple static file serving: if your app only serves static files, Express middleware is overkill. Use Nginx, Caddy, or a CDN directly for 10-100x better throughput.

Testing Strategy

  • Unit test middleware in isolation: create a minimal Express app with supertest, mount only the middleware under test, and make HTTP requests. Assert on response status, headers, and body. Mock next() to verify call order.
  • Integration test the full middleware stack: mount the complete Express app and test end-to-end request flow. Verify middleware ordering, error handling, and response transformations.
  • Test error paths explicitly: send malformed requests, trigger timeouts, and simulate downstream failures. Verify error-handling middleware catches and formats errors correctly.
  • Performance test middleware overhead: use autocannon to benchmark middleware overhead. Compare baseline (no middleware) vs full stack to identify bottlenecks. Target <5ms total middleware overhead per request.

Cost Estimation

ComponentCostNotes
Express (self-hosted)$0Open-source, MIT license
PM2 cluster mode$0Process manager, open-source
Redis (for session/rate limit)$10-$75/monthShared state across instances
Load balancer$20-$100/monthAWS ALB, GCP LB, Nginx
Monitoring (PM2 Plus)$0-$80/monthPM2 Plus, Datadog APM

For 10K req/s: 2x EC2 t3.large ($60/month) + Redis ($15/month) + ALB ($25/month) = ~$100/month. PM2 cluster mode is free. Add Datadog APM ($80/month) for production monitoring.

Monitoring and Observability

  • Track middleware execution time per request: use express-status-monitor or custom timing middleware to record how long each middleware takes. Alert if any middleware exceeds 10ms p95.
  • Monitor middleware error rates: count errors per middleware function. Set up alerts for error rate >1% on critical middleware (auth, CORS, rate limiting). Use prom-client to expose Prometheus metrics.
  • Log middleware order violations: if middleware executes out of order (e.g., auth after body parsing), log a warning. Middleware order bugs are hard to debug in production.
  • Track memory usage per middleware: some middleware (body-parser, session) allocates memory per request. Monitor heap growth and set up alerts for memory leaks. Use --max-old-space-size to limit heap and force garbage collection.

FAQ

Q: Should I use Express or Fastify for new projects? A: Fastify offers better performance and built-in schema validation. Express has larger ecosystem and familiarity. Both are viable for production. For Go-based APIs, see Go REST API with Gin.

Q: How do I test middleware in isolation? A: Create a mini Express app in tests, mount the middleware, and make supertest requests against it.

Is this solution production-ready?

Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.

What are the performance characteristics?

Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.

How do I debug issues with this approach?

Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.