.stringify(logEntry));
});
next();
}
**Architecture Rationale**: Correlation IDs must be injected before any downstream middleware runs. Using `performance.now()` provides higher precision than `Date.now()`. Attaching the ID to `req.headers` ensures it propagates to downstream services if the app acts as a client.
### 2. Traffic Quota Enforcement
Rate limiting must respect proxy headers and expose standard compliance headers. In-memory tracking is suitable for single-instance deployments; distributed setups require Redis or similar stores.
```typescript
import type { Request, Response, NextFunction } from 'express';
interface QuotaConfig {
windowMs: number;
maxRequests: number;
}
export function enforceQuota({ windowMs, maxRequests }: QuotaConfig) {
const store = new Map<string, { count: number; resetAt: number }>();
return function quotaMiddleware(req: Request, res: Response, next: NextFunction): void {
const clientIp = req.ip || 'unknown';
const now = Date.now();
let record = store.get(clientIp);
if (!record || now > record.resetAt) {
record = { count: 0, resetAt: now + windowMs };
}
record.count++;
store.set(clientIp, record);
res.setHeader('X-RateLimit-Limit', maxRequests);
res.setHeader('X-RateLimit-Remaining', Math.max(0, maxRequests - record.count));
res.setHeader('X-RateLimit-Reset', new Date(record.resetAt).toISOString());
if (record.count > maxRequests) {
res.status(429).json({ error: { code: 'QUOTA_EXCEEDED', message: 'Rate limit reached' } });
return;
}
next();
};
}
Architecture Rationale: Exposing X-RateLimit-* headers enables client-side backoff strategies. The cleanup interval should run independently to prevent memory leaks in long-running processes. For multi-node deployments, replace the Map with a distributed cache.
3. Identity Verification & Role Gating
Separate token validation from authorization checks. This enables flexible route composition where authentication is mandatory, but role requirements are conditional.
import jwt from 'jsonwebtoken';
import type { Request, Response, NextFunction } from 'express';
export function verifyBearerToken(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: { code: 'AUTH_MISSING', message: 'Bearer token required' } });
return;
}
try {
const payload = jwt.verify(authHeader.split(' ')[1], process.env.JWT_SECRET as string) as Record<string, unknown>;
req.user = payload;
next();
} catch {
res.status(401).json({ error: { code: 'TOKEN_INVALID', message: 'Verification failed' } });
}
}
export function requireRoles(...allowedRoles: string[]) {
return function roleGuard(req: Request, res: Response, next: NextFunction): void {
const userRole = req.user?.role as string;
if (!allowedRoles.includes(userRole)) {
res.status(403).json({ error: { code: 'INSUFFICIENT_PRIVILEGES', message: 'Role not permitted' } });
return;
}
next();
};
}
Architecture Rationale: Decoupling verification from authorization allows routes to compose middleware dynamically. Storing the decoded payload on req.user avoids repeated JWT decoding in downstream handlers.
Validation should occur after body parsing but before business logic. Schema libraries like Zod provide runtime type safety and automatic transformation.
import { ZodSchema, ZodError } from 'zod';
import type { Request, Response, NextFunction } from 'express';
export function enforceSchema(schema: ZodSchema) {
return function validationMiddleware(req: Request, _res: Response, next: NextFunction): void {
try {
const validated = schema.parse({
body: req.body,
query: req.query,
params: req.params,
});
Object.assign(req, validated);
next();
} catch (error) {
if (error instanceof ZodError) {
_res.status(422).json({
error: { code: 'CONTRACT_VIOLATION', message: 'Invalid payload', details: error.errors },
});
return;
}
next(error);
}
};
}
Architecture Rationale: Centralizing validation eliminates repetitive if/else checks in route handlers. Assigning the validated object back to req ensures downstream code operates on trusted data. Using ZodError enables precise error mapping for API consumers.
5. Async Route Safety
Express does not natively catch promise rejections in async route handlers. A wrapper function bridges this gap by routing rejections to the error handling middleware.
import type { Request, Response, NextFunction } from 'express';
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<void>;
export function wrapAsync(handler: AsyncHandler) {
return function asyncWrapper(req: Request, res: Response, next: NextFunction): void {
Promise.resolve(handler(req, res, next)).catch(next);
};
}
Architecture Rationale: This pattern prevents unhandled promise rejections from crashing the process or leaving requests in a pending state. It standardizes error propagation, ensuring all async failures flow through the centralized error handler.
6. Centralized Error Routing
Express identifies error-handling middleware by its four-parameter signature (err, req, res, next). It must be registered after all routes to catch unhandled exceptions.
import type { Request, Response, NextFunction } from 'express';
export function globalErrorHandler(err: Error, req: Request, res: Response, _next: NextFunction): void {
const statusCode = (err as any).statusCode || 500;
const isProduction = process.env.NODE_ENV === 'production';
console.error(`[TRACE:${req.headers['x-correlation-id']}] ${err.message}`);
res.status(statusCode).json({
error: {
code: (err as any).code || 'SERVER_FAILURE',
message: isProduction ? 'An unexpected error occurred' : err.message,
},
});
}
Architecture Rationale: Suppressing stack traces in production prevents information leakage. Correlation IDs in error logs enable rapid trace reconstruction. The fallback status code ensures unclassified errors default to 500.
Pitfall Guide
1. Middleware Order Inversion
Explanation: Registering authentication before body parsing, or placing the error handler before route definitions, breaks request flow and causes silent failures.
Fix: Follow the strict sequence: Security headers β CORS β Body parsing β Tracing β Health/Public routes β Authenticated routes β Error handler β 404 fallback.
2. Unhandled Promise Rejections
Explanation: Async route handlers that throw without try/catch or an async wrapper bypass Express error routing, potentially crashing the Node.js process in older versions or leaving connections hanging.
Fix: Wrap every async route with wrapAsync or use express-async-errors to patch the router prototype globally.
3. Proxy IP Blindness
Explanation: Rate limiters and logging middleware that read req.ip directly will capture the load balancer's IP when deployed behind reverse proxies, causing quota collisions and inaccurate telemetry.
Fix: Enable app.set('trust proxy', true) and verify that your hosting environment forwards X-Forwarded-For. Test with synthetic proxy headers during staging.
4. Synchronous Blocking in Middleware
Explanation: CPU-intensive operations (e.g., synchronous JSON parsing, heavy regex, or blocking I/O) in middleware stall the event loop, degrading throughput for all concurrent requests.
Fix: Offload heavy computation to worker threads or queue systems. Keep middleware strictly I/O-bound or lightweight transformation logic.
5. Overly Permissive CORS Configuration
Explanation: Using origin: '*' in production exposes APIs to cross-origin credential theft and CSRF attacks, especially when combined with credentials: true.
Fix: Maintain an allowlist of trusted origins. Validate the incoming Origin header against the list and dynamically set Access-Control-Allow-Origin only for matches.
6. Calling next() Multiple Times
Explanation: Accidentally invoking next() after sending a response, or in both success and error branches, triggers the Error: Cannot set headers after they are sent exception.
Fix: Use early returns after res.status().json() calls. Audit middleware branches to ensure exactly one control flow path executes per request.
7. Missing Cleanup in In-Memory Stores
Explanation: Rate limiters and session trackers using Map or WeakMap accumulate stale entries over time, leading to memory leaks and degraded performance in long-running processes.
Fix: Implement a periodic cleanup interval or switch to TTL-based distributed caches. Monitor heap usage with process.memoryUsage() during load testing.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal microservice | Minimal middleware, skip CORS, use internal auth | Reduces latency and simplifies deployment | Lower compute, faster cold starts |
| Public-facing API | Full security stack, rate limits, strict CORS, validation | Prevents abuse, ensures compliance, protects data | Moderate compute overhead, higher security ROI |
| Auth-heavy application | JWT verification + RBAC gate + session management | Centralizes identity logic, reduces route complexity | Slight latency increase, significant dev velocity gain |
| High-throughput service | Redis-backed rate limiting, async logging queue | Prevents memory leaks, avoids blocking event loop | Infrastructure cost increase, improved stability |
Configuration Template
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { injectTraceContext, structuredLogger } from './middleware/tracing';
import { enforceQuota } from './middleware/quota';
import { verifyBearerToken, requireRoles } from './middleware/auth';
import { enforceSchema } from './middleware/validation';
import { wrapAsync } from './middleware/async';
import { globalErrorHandler } from './middleware/errors';
import { userSchema, updateUserSchema } from './schemas/user';
const app = express();
// 1. Infrastructure & Security
app.set('trust proxy', 1);
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || [] }));
// 2. Request Parsing
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// 3. Observability & Traffic Control
app.use(injectTraceContext);
app.use(structuredLogger);
app.use('/api/', enforceQuota({ windowMs: 60_000, maxRequests: 100 }));
// 4. Health & Public Endpoints
app.get('/health', (_req, res) => {
res.json({ status: 'operational', timestamp: new Date().toISOString() });
});
// 5. Protected Routes
app.get('/api/profile', verifyBearerToken, wrapAsync(async (req, res) => {
res.json({ data: req.user });
}));
app.delete('/api/users/:id', verifyBearerToken, requireRoles('admin'), wrapAsync(async (req, res) => {
// Admin-only deletion logic
res.status(204).send();
}));
app.post('/api/users', enforceSchema(userSchema), verifyBearerToken, wrapAsync(async (req, res) => {
// req.body is validated and typed
res.status(201).json({ data: req.body.body });
}));
// 6. Error & Fallback Handlers
app.use(globalErrorHandler);
app.use((_req, res) => {
res.status(404).json({ error: { code: 'ROUTE_MISSING', message: 'Endpoint not found' } });
});
export default app;
Quick Start Guide
- Initialize the pipeline: Install
express, helmet, cors, zod, and jsonwebtoken. Create a middleware/ directory and export each utility function.
- Configure trust & security: Set
app.set('trust proxy', 1) if behind a proxy. Register helmet() and cors() immediately after app initialization.
- Attach tracing & parsing: Inject
injectTraceContext and structuredLogger before body parsers. Configure express.json() with a strict size limit to prevent payload abuse.
- Compose routes: Wrap async handlers with
wrapAsync. Apply enforceSchema before authentication for public endpoints, and after for protected ones. Chain requireRoles only where necessary.
- Finalize error routing: Register
globalErrorHandler as the last middleware. Add a 404 fallback to catch unmatched paths. Deploy and verify logs contain correlation IDs and structured metrics.