aces to reflect state mutations, and typing the NextFunction to handle both continuation and error forwarding.
import { Request, Response, NextFunction } from 'express';
export interface AuthenticatedRequest extends Request {
user: { id: string; role: 'admin' | 'user' } | null;
}
export type PipelineStage = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) => void;
Step 2: Implement Scoped Middleware Stages
Instead of global registration, middleware should be scoped to the routes that require it. This reduces unnecessary execution overhead and clarifies intent.
// telemetryInterceptor.ts
export const telemetryInterceptor: PipelineStage = (req, res, next) => {
const startTime = process.hrtime.bigint();
res.on('finish', () => {
const durationMs = Number(process.hrtime.bigint() - startTime) / 1_000_000;
console.log(`[${req.method}] ${req.originalUrl} β ${res.statusCode} (${durationMs.toFixed(2)}ms)`);
});
next();
};
// sessionGuard.ts
export const sessionGuard: PipelineStage = (req, res, next) => {
const token = req.headers['x-session-token'] as string | undefined;
if (!token) {
return res.status(401).json({ code: 'MISSING_CREDENTIALS' });
}
try {
const payload = decodeSessionToken(token);
req.user = payload;
next();
} catch (err) {
next(new Error('INVALID_SESSION'));
}
};
// payloadSanitizer.ts
export const payloadSanitizer: PipelineStage = (req, res, next) => {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({ code: 'INVALID_PAYLOAD' });
}
req.body = sanitizeInput(req.body);
next();
};
Step 3: Assemble the Execution Stack
Express evaluates middleware in registration order. The stack must be constructed deliberately: telemetry first (to capture all requests), followed by security/validation, then route handlers, and finally the error boundary.
import express, { Application } from 'express';
import { telemetryInterceptor } from './telemetryInterceptor';
import { sessionGuard } from './sessionGuard';
import { payloadSanitizer } from './payloadSanitizer';
const app: Application = express();
app.use(express.json());
app.use(telemetryInterceptor);
const router = express.Router();
router.use('/secure', sessionGuard);
router.use('/secure', payloadSanitizer);
router.post('/secure/data', (req, res) => {
res.json({ status: 'accepted', userId: req.user!.id });
});
app.use('/api', router);
Step 4: Implement the Error Boundary
Error-handling middleware requires exactly four parameters. Express identifies it by signature, not by name. It must be registered after all route handlers to catch propagated errors.
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
const statusCode = err.message === 'INVALID_SESSION' ? 401 : 500;
res.status(statusCode).json({
code: statusCode === 401 ? 'AUTH_FAILURE' : 'INTERNAL_ERROR',
message: process.env.NODE_ENV === 'production' ? undefined : err.message
});
});
Architecture Rationale
- Top-down registration: Express maintains an internal array of middleware layers. Order is deterministic. Placing telemetry first ensures latency measurement covers the entire pipeline.
- Router-level scoping: Applying
sessionGuard and payloadSanitizer to /secure prevents unnecessary token decoding and input sanitization for public endpoints, reducing CPU overhead.
- Explicit error forwarding: Using
next(err) instead of res.status().json() inside middleware preserves the pipeline contract. It allows centralized error formatting and prevents duplicate response sends.
- TypeScript declaration merging: Extending
Request ensures type safety across stages. Downstream handlers can rely on req.user without runtime type guards.
Pitfall Guide
1. The Hanging Connection
Explanation: Failing to call next(), res.send(), or res.end() leaves the request in an open state. Node.js will eventually terminate the connection after the server timeout, but the client experiences a silent hang.
Fix: Always ensure every code path in a middleware function either advances the pipeline or terminates the response. Use exhaustive if/else or switch statements to guarantee coverage.
2. Async Middleware Without Error Forwarding
Explanation: Express v4 does not natively catch rejected promises inside middleware. An unhandled async error will crash the process or silently fail, depending on the Node.js version.
Fix: Wrap asynchronous middleware in a try/catch block and forward errors via next(err), or use a wrapper utility:
const asyncHandler = (fn: PipelineStage) => (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
3. Misplaced Error Boundary
Explanation: Registering the four-argument error handler before route handlers causes it to be skipped entirely. Express only routes to error middleware when next(err) is called or an uncaught exception occurs downstream.
Fix: Always place error-handling middleware at the very end of the registration sequence, after all app.use() and app.METHOD() calls.
4. Global Middleware for Route-Specific Logic
Explanation: Attaching authentication or validation middleware globally via app.use() forces every request to pass through unnecessary checks. This increases latency and complicates routing logic.
Fix: Use router.use() to scope middleware to specific path prefixes. Reserve app.use() for cross-cutting concerns like logging, CORS, or body parsing.
5. Silent req Mutation
Explanation: Attaching arbitrary properties to req without TypeScript definitions or documentation leads to runtime undefined errors and makes debugging difficult. Downstream handlers cannot guarantee the presence of expected data.
Fix: Define explicit interfaces for extended request objects. Use optional chaining or runtime validation when accessing custom properties. Document mutations in middleware JSDoc comments.
6. Blocking the Event Loop
Explanation: Synchronous CPU-intensive operations (e.g., heavy regex, large JSON parsing, cryptographic hashing) inside middleware block the single-threaded event loop. This stalls all incoming connections.
Fix: Offload heavy computation to worker threads, external services, or asynchronous streams. Use express.json({ limit: '1mb' }) to prevent payload parsing from consuming excessive memory.
7. Duplicate Response Sends
Explanation: Calling res.json() followed by next() results in an ERR_HTTP_HEADERS_SENT error. Express cannot send multiple responses for a single request.
Fix: Treat response sending as a terminal action. If you send a response, return immediately. Never call next() after res.send(), res.json(), or res.end().
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public API endpoints | Router-level middleware with explicit allowlists | Reduces unnecessary processing overhead | Lower CPU/memory usage |
| Internal microservice communication | Global middleware with service-to-service token validation | Simplifies routing logic across multiple services | Moderate increase in startup complexity |
| High-throughput logging | Stream-based telemetry with async batching | Prevents event loop blocking from synchronous console I/O | Higher infrastructure cost for log aggregation |
| Strict input validation | Pipeline-stage sanitizer with schema enforcement | Catches malformed payloads before business logic execution | Slight latency increase, reduced database errors |
| Legacy migration | Gradual middleware adoption with feature flags | Allows incremental refactoring without breaking existing routes | Development time investment, lower risk |
Configuration Template
import express, { Application, Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
// Type extensions
declare global {
namespace Express {
interface Request {
requestId: string;
processedAt: number;
}
}
}
const app: Application = express();
// 1. Cross-cutting concerns
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));
// 2. Request tracing
app.use((req: Request, res: Response, next: NextFunction) => {
req.requestId = crypto.randomUUID();
req.processedAt = Date.now();
res.setHeader('X-Request-Id', req.requestId);
next();
});
// 3. Route registration
const apiRouter = express.Router();
apiRouter.use('/protected', requireAuth, validateInput);
apiRouter.get('/protected/resource', handleResource);
app.use('/v1', apiRouter);
// 4. Error boundary
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(`[${req.requestId}] ${err.message}`);
res.status(500).json({ error: 'SERVICE_UNAVAILABLE' });
});
export { app };
Quick Start Guide
- Initialize the project: Run
npm init -y && npm install express typescript @types/express ts-node
- Configure TypeScript: Create
tsconfig.json with strict: true, esModuleInterop: true, and outDir: ./dist
- Create the entry file: Set up
src/server.ts with the configuration template above, replacing placeholder handlers with your business logic
- Register middleware in order: Place parsing and security middleware first, route-specific middleware second, and error handling last
- Start the server: Run
npx ts-node src/server.ts and verify pipeline execution using curl -X POST http://localhost:3000/v1/protected/resource -H "Content-Type: application/json" -d '{"test": true}'
Middleware orchestration transforms Express from a simple routing library into a deterministic request processing engine. By enforcing execution order, isolating concerns, and respecting the pipeline contract, you build applications that are predictable under load, straightforward to debug, and resilient against common failure modes.