It is easy to write an Express app. It is hard to write an Express app that survives production.
A "Hello World" tutorial won't tell you how to trace a specific bug through 10,000 requests per second, or how to prevent your server from leaking stack traces to hackers.
Here is a blueprint for hardening your Node.js/Express architecture using modern patterns like AsyncLocalStorage, structured logging, and strict validation.
1. Traceability: The AsyncLocalStorage Pattern
In a complex app, passing a requestId or logger instance down through every controller, service, and database repository is messy (backend "prop drilling").
Node.js offers a native solution: AsyncLocalStorage (ALS). It allows you to store data that is unique to the current asynchronous execution context (i.e., the current request) without passing arguments.
The Setup
First, create a storage file.
// src/lib/store.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestContext {
requestId: string;
userId?: string;
}
export const storage = new AsyncLocalStorage<RequestContext>();
export const getRequestId = () => {
const store = storage.getStore();
return store?.requestId || 'system';
};
The Middleware
Next, create a middleware that generates a unique ID (UUID) for every incoming request and wraps the rest of the request lifecycle in the store.
// src/middleware/context.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { storage } from '../lib/store';
export const contextMiddleware = (req: Request, res: Response, next: NextFunction) => {
const requestId = (req.headers['x-request-id'] as string) || uuidv4();
// Attach to response header so the client knows the ID too
res.setHeader('x-request-id', requestId);
// Run the rest of the app within the context
storage.run({ requestId }, () => {
next();
});
};
Now, anywhere in your code (even deep inside a database helper), you can call getRequestId() to know exactly which request triggered the code.
2. Structured Logging with Context
console.log is insufficient for production. You need structured logging (JSON) so tools like Datadog, CloudWatch, or ELK can parse it.
Using libraries like Pino combined with our ALS setup allows us to automatically inject the requestId into every single log line without manually adding it.
// src/lib/logger.ts
import pino from 'pino';
import { getRequestId } from './store';
export const logger = pino({
mixin() {
// Automatically adds requestId to every log object
return { requestId: getRequestId() };
},
transport: {
target: process.env.NODE_ENV === 'development' ? 'pino-pretty' : undefined,
},
});
Usage:
logger.info("User logged in");
// Output: { "level": 30, "time": 123456, "msg": "User logged in", "requestId": "abc-123" }
3. Security: The "Low Hanging Fruit" Headers
Do not deploy without Helmet. It sets various HTTP headers to secure your app against common attack vectors like XSS (Cross-Site Scripting) and Clickjacking.
import helmet from 'helmet';
// Enable standard security headers
app.use(helmet());
// Specifically configure Content Security Policy (CSP) if you serve UI
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "trusted-cdn.com"],
},
})
);
Rate Limiting
Prevent brute-force attacks and DoS. While this is often better handled at the Load Balancer (AWS ALB) or Nginx level, having an application-level safety net is best practice.
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
4. Input Validation: Trust Nothing
TypeScript types are erased at runtime. You cannot rely on TS interfaces to validate incoming JSON bodies. Use a runtime schema validator like Zod.
Create a generic middleware to enforce schema compliance:
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
export const validate = (schema: AnyZodObject) =>
(req: Request, res: Response, next: NextFunction) => {
try {
schema.parse({
body: req.body,
query: req.query,
params: req.params,
});
next();
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({ errors: error.errors });
}
next(error);
}
};
5. Centralized Error Handling
Never send a raw 500 stack trace to the client. It exposes your internal file structure and potential vulnerabilities.
- Create a custom
AppErrorclass to distinguish between Operational Errors (bad input, not found) and Programmer Errors (bugs). - Use a global error handling middleware at the end of your app.
// src/middleware/errorHandler.ts
export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
const requestId = getRequestId();
// Log the error with the trace ID for debugging
logger.error({ err, requestId }, "Unhandled Error");
// Send a sanitized response to the user
res.status(500).json({
status: 'error',
message: 'Internal Server Error',
requestId, // Handy for the user to give to customer support
});
};
6. Graceful Shutdown
When you deploy a new version to AWS/Heroku/Kubernetes, the orchestrator sends a SIGTERM signal. If you don't handle this, Express kills active connections immediately, resulting in dropped requests for users.
const server = app.listen(3000);
const gracefulShutdown = () => {
logger.info("SIGTERM received. Shutting down gracefully...");
server.close(() => {
logger.info("HTTP server closed.");
// Close Database connections here
// await db.disconnect();
process.exit(0);
});
// Force close after 10s if connections hang
setTimeout(() => process.exit(1), 10000);
};
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
Summary Checklist
- Traceability: Implemented
AsyncLocalStoragewith UUIDs. - Logging: Structured JSON logs (Pino) auto-tagged with Request IDs.
- Security: Helmet headers and Rate Limiting active.
- Validation: Zod schemas for all inputs.
- Failures: Global error handler sanitizes output.
- Lifecycle: Graceful shutdown handles deployments smoothly.
Top comments (0)