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

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. 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).
  • 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.
  • 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.
  • Simple static file serving: if your app only serves static files, Express middleware is overkill.

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. 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).
  • 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.

Troubleshooting

  • 5xx errors under load: check rate limits, connection pools, and downstream timeouts.
  • CORS errors in the browser: confirm allowed origins, methods, and headers. Preflight requests must return the right headers before the actual request.
  • Unexpected 404s: verify route definitions, path parameters, and base paths. Watch for trailing slashes and URL encoding differences.
  • Authentication failures: validate token expiry, signature algorithms, and clock skew. Log rejected tokens without exposing secrets.
  • Slow response times: profile the slowest percentiles.

Further Reading

  • Official documentation: check the current reference for the framework or tool used.
  • Related guides: explore the express and nodejs guides for deeper coverage.
  • Complementary patterns: review design patterns applicable to your technology stack.
  • Public postmortems: study real incidents from teams that faced similar production issues.

Production Notes

  • Deploy gradually using canary or blue-green to catch regressions early.
  • Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
  • Document the rollback in the runbook; test the procedure in staging at least once per quarter.
  • Review structured logs with correlation IDs to trace requests end-to-end during incidents.

Key Takeaways

  • Apply express.js middleware composition patterns when you need a practical solution for your use case.
  • Monitor performance after implementation; measure latency, errors, and resource usage before and after.
  • Check the Troubleshooting section for common failures; most have documented root causes with fixes.
  • Keep dependencies updated and run tests in CI to prevent production regressions.

Common Production Pitfalls

  • Copying the example without adapting it to real data volumes and failure modes.
  • Skipping load and error-injection tests before the first production deployment.
  • Hard-coding values that should be configurable per environment.
  • Forgetting to add logging and monitoring at each step.
  • Deploying without a rollback plan or a tested backup strategy.
  • Assuming the minimal example will scale without adding caching or batching.
  • Not documenting the version and configuration used in production.
  • Letting the recipe sit unchanged when dependencies or scale evolve.

Frequently Asked Questions

Should I use Express or Fastify for new projects?

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.

How do I test middleware in isolation?

Create a mini Express app in tests, mount the middleware, and make supertest requests against it.