The companion deck to Introduction to Express.js — everything past "Hello World": project structure at scale, async error discipline, OpenAPI-first design, security hardening, observability, performance and graceful shutdown.
Express 5 (stable in 2024) is the first major release in nine years. It modernises async handling and tightens routing — small surface, real consequences. Most apps need a focused migration, not a rewrite.
express-async-errors:param still works; ReDoS-prone wildcards rejected by defaultexpress.json(), express.urlencoded(), express.text() — no separate body-parserreq.query parsing — opt-in extended syntax// Express 4 — needed try/catch or express-async-errors
app.get('/u/:id', async (req, res, next) => {
try {
res.json(await getUser(req.params.id));
} catch (err) { next(err); }
});
// Express 5 — promise rejections forwarded automatically
app.get('/u/:id', async (req, res) => {
res.json(await getUser(req.params.id));
});
app.del() removed — use app.delete()'/foo*' now require explicit syntax ('/foo{*splat}' or a RegExp literal)req.param() removed — use req.params / req.query / req.bodyfalse — opt in behind a load balancer, or X-Forwarded-For is ignoredres.send(status) overload removed — use res.status(n).send()res.redirect(url, status) → res.redirect(status, url)body-parser, express-async-errorsapp.set('trust proxy', 1) if you front with a proxy / LBThe single biggest source of "the server fell over" is unhandled rejections that escape the request lifecycle. The fix is a disciplined error pipeline — one place where every error becomes an HTTP response.
// errors.js
class HttpError extends Error {
constructor(status, code, message, details) {
super(message);
this.status = status;
this.code = code;
this.details = details;
}
}
const NotFound = (m='Not found') =>
new HttpError(404, 'not_found', m);
const Conflict = (m, d) =>
new HttpError(409, 'conflict', m, d);
module.exports = { HttpError, NotFound, Conflict };
// errorMiddleware.js — mounted last
module.exports = (err, req, res, _next) => {
const status = err.status ?? 500;
if (status >= 500) req.log.error({ err }, 'unhandled');
res.status(status).json({
error: { code: err.code ?? 'internal',
message: status >= 500 ? 'Internal error' : err.message,
details: err.details, request_id: req.id }
});
};
app.get('/users/:id', async (req, res) => {
const user = await users.byId(req.params.id);
if (!user) throw NotFound('user not found');
res.json(user);
});
// Zod validation — throw on failure
app.post('/users', async (req, res) => {
const body = UserCreate.parse(req.body); // ZodError on bad input
res.status(201).json(await users.create(body));
});
// translate library errors at one place — before the json handler
app.use((err, req, res, next) => {
if (err instanceof ZodError)
return next(new HttpError(400, 'validation_failed',
'Invalid request', err.flatten()));
if (err.code === '23505')
return next(new HttpError(409, 'duplicate',
'Already exists'));
next(err);
});
process.on('unhandledRejection', (err) => {
log.fatal({ err }, 'unhandledRejection');
shutdown(1); // never swallow — exit cleanly
});
process.on('uncaughtException', (err) => {
log.fatal({ err }, 'uncaughtException');
shutdown(1);
});
HttpError shape, one error middlewareThe intro deck shows app.js + routes/ + controllers/. That works to ~5 kLOC. Past that you'll want one of two shapes: layered (technology cuts) or feature-based (domain cuts).
src/
├── app.js # composition root
├── server.js # boots app + db + queues
├── routes/ # express routers
│ ├── users.js
│ └── orders.js
├── controllers/ # http <-> service glue
├── services/ # domain logic
├── repositories/ # db access (knex/prisma)
├── schemas/ # Zod request/response
├── middleware/ # auth, rate limit, errors
├── lib/ # cross-cutting helpers
└── config/
src/
├── app.js
├── server.js
├── core/ # cross-cutting: logger, db, queue
├── modules/
│ ├── users/
│ │ ├── users.routes.js
│ │ ├── users.controller.js
│ │ ├── users.service.js
│ │ ├── users.repo.js
│ │ ├── users.schemas.js
│ │ └── users.test.js
│ ├── orders/
│ └── billing/
└── shared/ # DTOs & types used by >1 module
// src/app.js — pure composition, no global singletons
function createApp({ db, queue, logger }) {
const app = express();
app.set('trust proxy', 1);
app.use(requestId());
app.use(httpLogger(logger));
app.use(express.json({ limit: '128kb' }));
app.use(rateLimit());
app.use('/v1/users', usersRouter({ db, queue }));
app.use('/v1/orders', ordersRouter({ db, queue }));
app.get('/healthz', (_q,r) => r.json({ ok: true }));
app.get('/readyz', ready({ db, queue }));
app.use(notFound);
app.use(errorHandler);
return app;
}
module.exports = { createApp };
require('./db') in service code — everything injectedapp with mocks — no global teardown/v1 from day one — versioning is free laterValidate once, at the edge, with a single tool. The output of validation is your only input to handlers — raw req.body never touches the service layer.
import { z } from 'zod';
const UserCreate = z.object({
email: z.string().email(),
name: z.string().min(1).max(120),
role: z.enum(['user','admin']).default('user'),
});
type UserCreate = z.infer<typeof UserCreate>;
// reusable middleware
const validate = (schema, source = 'body') =>
(req, _res, next) => {
const r = schema.safeParse(req[source]);
if (!r.success) return next(
new HttpError(400, 'validation_failed',
'Invalid request', r.error.flatten()));
req[source] = r.data;
next();
};
usersRouter.post('/', validate(UserCreate), createUser);
body — JSON or form payloadparams — path parameters — always validate UUIDs / IDsquery — coerce strings to numbers / booleans / datesheaders — for API keys, content-type, idempotency keys// query strings are always strings — coerce at the schema
const UserList = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().min(1).max(100).default(20),
q: z.string().trim().optional(),
});
usersRouter.get('/',
validate(UserList, 'query'),
async (req, res) => {
// req.query is now { page: number, size: number, q?: string }
res.json(await users.list(req.query));
});
| Tool | Strength | Pick when |
|---|---|---|
| Zod | TS inference | TypeScript codebase |
| Joi | Mature, expressive | JS / older codebase |
| TypeBox | JSON Schema native | Want OpenAPI from schema |
| express-validator | Built for Express | Per-field chained API |
Don't trust req.body directly, don't validate inside the controller, don't have two validation libraries in one repo.
Three common shapes, each with a Right Way and a load-bearing detail. The intro deck shows mechanics; this one is choices and pitfalls.
HttpOnly · Secure · SameSite=Laxapp.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET,
cookie: { httpOnly: true, secure: true,
sameSite: 'lax', maxAge: 30*60*1000 },
rolling: true, resave: false, saveUninitialized: false,
}));
iss / aud / exp / nbf — never just decode// verify in middleware
const { payload } = await jwtVerify(token, jwks, {
issuer: ISS, audience: AUD,
algorithms: ['RS256','ES256']
});
req.user = { sub: payload.sub, scope: payload.scope };
X-Api-Key; rotate on a scheduleasync function apiKey(req, _res, next) {
const k = req.get('x-api-key');
if (!k) throw Unauthorized();
const row = await keys.byHash(sha256(k));
if (!row || row.revoked) throw Unauthorized();
req.caller = { keyId: row.id, scopes: row.scopes };
next();
}
Cross-cutting: always rate-limit the login / refresh endpoints separately; log auth failures with userless metadata; never leak whether the email exists in the error message.
Authentication answers who; authorisation answers may they. Encode it as middleware that throws — the same pipeline that handles validation handles AuthZ failures.
// require any of the listed scopes
const requireScope = (...scopes) => (req, _res, next) => {
if (!req.user) throw Unauthorized();
const have = new Set((req.user.scope ?? '').split(' '));
if (!scopes.some(s => have.has(s))) throw Forbidden();
next();
};
usersRouter.get ('/', requireScope('users:read'), list);
usersRouter.post('/', requireScope('users:write'), create);
// pluck the user-or-admin variant
const requireSelfOrAdmin = (req, _res, next) => {
if (req.user.role === 'admin') return next();
if (req.user.sub === req.params.id) return next();
throw Forbidden();
};
usersRouter.get('/:id', requireSelfOrAdmin, byId);
// load resource then check — one DB hit, clear ownership rule
async function ownPost(req, _res, next) {
const post = await posts.byId(req.params.id);
if (!post) throw NotFound();
if (post.author_id !== req.user.sub &&
req.user.role !== 'admin') throw Forbidden();
req.post = post; // hand off to handler
next();
}
postsRouter.put('/:id', ownPost, update);
postsRouter.delete('/:id', ownPost, destroy);
// thin shim — offload the decision
async function authorise(req, _res, next) {
const decision = await policy.evaluate({
principal: req.user,
action: `${req.method} ${req.route.path}`,
resource: req.post ?? req.params,
context: { ip: req.ip, time: Date.now() },
});
if (!decision.allow) throw Forbidden(decision.reasons);
next();
}
req.user.scopes), not per processinfo; include request_id + principal + actionreq.user.role from the body / queryMost APIs return JSON in one res.json(). Some don't — downloads, SSE, progress, large reports — benefit from streaming. Get it wrong and you OOM the process.
// BAD — reads whole file into memory
app.get('/r/:id', async (req, res) => {
const buf = await fs.readFile(path);
res.type('application/pdf').send(buf);
});
// GOOD — pipeline with cleanup on abort
const { pipeline } = require('node:stream/promises');
app.get('/r/:id', async (req, res) => {
res.type('application/pdf');
res.setHeader('Content-Disposition',
`attachment; filename="r-${req.params.id}.pdf"`);
await pipeline(
fs.createReadStream(path),
res
); // closes both sides on error
});
app.get('/jobs/:id/stream', async (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // disable nginx buffer
});
res.flushHeaders();
const send = (e, d) =>
res.write(`event: ${e}\ndata: ${JSON.stringify(d)}\n\n`);
const sub = events.subscribe(req.params.id, send);
req.on('close', () => sub.unsubscribe());
});
// for very large lists — ndjson is the right answer most of the time
app.get('/users.ndjson', async (req, res) => {
res.type('application/x-ndjson');
for await (const row of knex('users').stream()) {
if (!res.write(JSON.stringify(row) + '\n'))
await once(res, 'drain'); // back-pressure
}
res.end();
});
// Express handles HEAD / If-Modified-Since / If-None-Match / Range
// when you use res.sendFile() / res.download()
app.get('/audio/:id', (req, res) => {
res.sendFile(absPath, {
acceptRanges: true,
cacheControl: true,
maxAge: '1h'
});
});
compression() in front of SSE — chunked output gets buffered → silent clientreq.on('close') — orphaned subscribers leak memoryX-Accel-Buffering: no behind nginxThree real choices — memory, disk or direct-to-S3. Pick by file size, retention, and whether your app instance should ever own the bytes.
const multer = require('multer');
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, // 5 MB
files: 1 },
fileFilter: (_req, file, cb) => {
if (!/^image\/(png|jpe?g|webp)$/.test(file.mimetype))
return cb(new HttpError(415, 'unsupported_media',
'image png/jpg/webp only'));
cb(null, true);
}
});
app.post('/avatar',
requireAuth,
upload.single('image'),
async (req, res) => {
const url = await s3.put(req.file.buffer, key,
{ ContentType: req.file.mimetype });
res.json({ url });
});
// stream to disk, then move — keeps memory flat
const upload = multer({
storage: multer.diskStorage({ destination: '/var/uploads' }),
limits: { fileSize: 100 * 1024 * 1024 }
});
// 1) sign a one-time PUT URL on the server
app.post('/uploads/sign', requireAuth, async (req, res) => {
const { contentType, contentLength } = SignReq.parse(req.body);
if (contentLength > 50 * 1024 * 1024)
throw HttpError(413, 'too_large', 'max 50MB');
const key = `u/${req.user.sub}/${ulid()}`;
const url = await getSignedUrl(s3, new PutObjectCommand({
Bucket: BUCKET, Key: key,
ContentType: contentType
}), { expiresIn: 60 });
res.json({ url, key });
});
// 2) browser uploads to S3, then POSTs the key back
app.post('/photos', requireAuth, async (req, res) => {
const { key } = AttachReq.parse(req.body);
await photos.create({ user: req.user.sub, key });
res.status(201).end();
});
App never sees the bytes → no RAM spike, no egress cost, fewer middleware concerns. The same pattern works for GCS / Azure Blob.
fileSize and total request size at the proxyThree layers, each catching a different failure: global (DoS), per-user (fairness), per-endpoint (login brute force). Implement all three; they don't replace each other.
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis').default;
const store = new RedisStore({
sendCommand: (...args) => redis.call(...args)
});
// 1) global — per IP, all routes
const globalLimiter = rateLimit({
store, windowMs: 60_000, max: 600,
standardHeaders: 'draft-7', legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
app.use(globalLimiter);
// 2) per-user — mounted after auth
const userLimiter = rateLimit({
store, windowMs: 60_000, max: 1200,
keyGenerator: (req) => req.user?.sub ?? req.ip,
});
app.use('/v1', requireAuth, userLimiter);
const loginLimiter = rateLimit({
store, windowMs: 15 * 60_000, max: 5,
keyGenerator: (req) => `${req.ip}:${req.body.email}`,
skipSuccessfulRequests: true, // only count failures
});
authRouter.post('/login', loginLimiter, login);
express-rate-limit v7express-slow-down adds latency before blocking; gentler UXexpress.json({ limit: '128kb' }) — the cheapest DoS defencep-limit on expensive operationsSet app.set('trust proxy', 1) — otherwise req.ip is the proxy and everyone shares one bucket.
Four layers, top to bottom: CDN, conditional GETs (ETag / Last-Modified), Redis cache-aside, in-process memo. Each catches different cost.
// public, immutable assets
app.use('/static', express.static('public', {
immutable: true, maxAge: '365d'
}));
// private, per-user, never cached upstream
app.use('/v1/me', (req, res, next) => {
res.set('Cache-Control',
'private, no-store, no-cache, must-revalidate');
next();
});
// cdn-cacheable list, short TTL
app.get('/v1/posts', cache(60), listPosts);
function cache(maxAge) {
return (_req, res, next) => {
res.set('Cache-Control',
`public, max-age=${maxAge}, stale-while-revalidate=30`);
next();
};
}
// Express defaults to weak ETag on res.json/.send
app.set('etag', 'strong');
// or compute your own from a row's updated_at + version
app.get('/v1/posts/:id', async (req, res) => {
const p = await posts.byId(req.params.id);
if (!p) throw NotFound();
const tag = `"${p.id}-${p.version}"`;
if (req.get('if-none-match') === tag) return res.status(304).end();
res.set('ETag', tag).json(p);
});
async function postById(id) {
const k = `post:${id}`;
const hit = await redis.get(k);
if (hit) return JSON.parse(hit);
const row = await db('posts').where({ id }).first();
if (row) await redis.set(k, JSON.stringify(row), 'EX', 60);
return row;
}
// invalidate on write
async function updatePost(id, patch) {
await db('posts').where({ id }).update(patch);
await redis.del(`post:${id}`);
}
When the key expires under load, every concurrent request misses and hits the DB. Mitigate with single-flight (one request fills the cache, others wait), stale-while-revalidate at the cache layer, or jittered TTLs (EX 60 + rand(0,10)).
One pool per process; one transaction per request that needs one; never require('./db') deep in services. The shape that scales is DI from the composition root.
// repositories/users.js
module.exports = (db) => ({
byEmail: (e) => db('users').where({ email: e }).first(),
create: (i) => db('users').insert(i).returning('*'),
});
// services/users.js
module.exports = ({ usersRepo, queue, logger }) => ({
async signup(input) {
const exists = await usersRepo.byEmail(input.email);
if (exists) throw Conflict('email taken');
const [user] = await usersRepo.create(input);
await queue.add('welcome-email', { id: user.id });
return user;
}
});
// composition
const usersRepo = repos.users(db);
const usersService = services.users({ usersRepo, queue, logger });
app.use('/v1/users', usersRouter({ usersService }));
// when a single endpoint must be atomic
function withTransaction(db) {
return async (req, _res, next) => {
req.tx = await db.transaction();
res.on('finish', () => {
if (res.statusCode < 400) req.tx.commit();
else req.tx.rollback();
});
res.on('close', () => req.tx.rollback().catch(()=>{}));
next();
};
}
// use sparingly — only on endpoints that need it
postsRouter.post('/transfer', withTransaction(db), transfer);
Two pools, two clients: writer and reader. Default to writer for the request lifetime; opt in to reader for clearly idempotent GETs. Be honest about replication lag — "I just wrote then read" returns stale data.
Expose /readyz that runs SELECT 1 through the pool. Liveness (/healthz) only checks the process is alive — don't let DB outages restart the pod and lose warm caches.
If a request is taking > 200ms because of a side-effect (email, webhook, image resize), enqueue, don't await. The classic Node stack is BullMQ on Redis — battle-tested, observable, with retries and DLQ.
// queue.js — one queue per logical job kind
const { Queue } = require('bullmq');
const emails = new Queue('emails', { connection: redis });
// in a service
async function signup(input) {
const user = await usersRepo.create(input);
await emails.add('welcome', { userId: user.id }, {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 1000,
removeOnFail: 500,
});
return user;
}
// worker.js — runs in its own process
const { Worker } = require('bullmq');
new Worker('emails', async (job) => {
const u = await users.byId(job.data.userId);
await mailer.send({ to: u.email, template: job.name });
}, { connection: redis, concurrency: 10 });
// SIGTERM → await worker.close() → exit
jobId derived from the workoutbox in the same transaction; a relay process pushes to BullMQattempts land in a "failed" view; alert on growthIf the work is < 50ms and rarely fails, in-process setImmediate or just-do-it is fine. Queues add operational surface; pay for them when retries / parallelism / decoupling actually matter.
Three layers, fastest first: unit (pure functions), HTTP integration (supertest against a built app), contract (against the real OpenAPI). Most teams over-invest in unit and under-invest in HTTP.
const request = require('supertest');
const { createApp } = require('../src/app');
let app, db;
beforeAll(async () => {
db = await openTestDb();
app = createApp({ db, queue: fakeQueue, logger });
await db.migrate.latest();
});
afterAll(async () => { await db.destroy(); });
test('POST /v1/users returns 201', async () => {
const res = await request(app)
.post('/v1/users')
.send({ email: 'a@x.io', name: 'Alice' })
.expect(201);
expect(res.body).toMatchObject({ email: 'a@x.io' });
});
test('rejects invalid body', async () => {
await request(app).post('/v1/users').send({}).expect(400);
});
// helper: build an authenticated agent
async function asUser(app, claims = {}) {
const token = await signTestToken({ sub: 'u_1', ...claims });
const a = request.agent(app);
a.set('authorization', `Bearer ${token}`);
return a;
}
const me = await asUser(app, { scope: 'users:read' });
await me.get('/v1/users/me').expect(200);
// Validate every response against the OpenAPI document
const OpenAPIResponseValidator =
require('openapi-response-validator').default;
const spec = require('./openapi.json');
const validator = new OpenAPIResponseValidator({
responses: spec.paths['/v1/users/{id}'].get.responses,
components: spec.components,
});
test('GET /v1/users/{id} matches the contract', async () => {
const res = await request(app).get('/v1/users/u_1').expect(200);
const errs = validator.validateResponse(200, res.body);
expect(errs).toBeUndefined();
});
| Dependency | In tests |
|---|---|
| Database | Real (SQLite or Testcontainers PG) |
| Queue | In-memory fake; assert .add(...) calls |
| HTTP downstream | nock / msw |
| Time | jest.useFakeTimers() — never real Date.now() in assertions |
| Crypto / IDs | Inject a generator; freeze in tests |
Don't test the framework — testing that "app.get works" is noise. Test your contract: status, body shape, side-effects.
Three pillars: structured logs, RED metrics, distributed traces. The minimum competent setup is pino + OpenTelemetry + a request-ID middleware — ship that, then iterate.
const pino = require('pino');
const pinoHttp = require('pino-http');
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: ['req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]'],
formatters: { level: (l) => ({ level: l }) },
});
app.use(pinoHttp({
logger,
genReqId: (req) => req.headers['x-request-id'] ?? randomUUID(),
customLogLevel: (_, res, err) =>
err || res.statusCode >= 500 ? 'error'
: res.statusCode >= 400 ? 'warn' : 'info',
}));
// in any handler:
app.get('/v1/users/:id', (req, res) =>
req.log.info({ id: req.params.id }, 'fetching user'));
Rate, Errors, Duration — per route, per status. prom-client + a histogram middleware exports them; Grafana draws the panel.
// otel.js — required *before* express
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } =
require('@opentelemetry/auto-instrumentations-node');
new NodeSDK({
serviceName: 'api',
instrumentations: [getNodeAutoInstrumentations()],
}).start();
// every request gets a trace; HTTP / pg / redis spans appear
// for free. Add manual spans for hot business logic.
x-request-id if present; otherwise generate (ULID / UUID v7)span.setAttribute('http.request_id', id)console.log(req.body) — PII / tokens / passwordsSix controls cover most of the OWASP Top 10 for a Node API. None are optional in production.
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: {
'default-src': ["'self'"],
'script-src': ["'self'", "'strict-dynamic'", "'nonce-PLACEHOLDER'"],
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'data:', 'https://cdn.example'],
'connect-src': ["'self'", 'https://api.example'],
'object-src': ["'none'"],
'frame-ancestors': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"],
'upgrade-insecure-requests': [],
},
},
crossOriginOpenerPolicy: { policy: 'same-origin' },
crossOriginResourcePolicy: { policy: 'same-site' },
referrerPolicy: { policy: 'no-referrer' },
}));
const cors = require('cors');
const allow = new Set(['https://app.example.com',
'https://admin.example.com']);
app.use(cors({
origin: (origin, cb) => cb(null, !origin || allow.has(origin)),
credentials: true,
methods: ['GET','POST','PUT','PATCH','DELETE'],
allowedHeaders: ['authorization','content-type','idempotency-key'],
maxAge: 600,
}));
// only needed when the browser sends cookies automatically
const csurf = require('csurf');
app.use(csurf({
cookie: { httpOnly: true, secure: true, sameSite: 'lax' }
}));
app.use((req, res, next) => {
res.locals.csrfToken = req.csrfToken();
next();
});
// SameSite=Lax + double-submit token blocks the
// classic GET-redirect-then-POST CSRF.
For pure JWT-Bearer APIs (no cookies), CSRF doesn't apply — but check every endpoint to be sure.
express.json({ limit: '128kb' }) + proxy capHttpOnly · Secure · SameSite=Lax (or None + cross-site CSRF token)npm audit, Renovate, lockfile, no --unsafe-permTrusting input. Validate it, escape it on output, parameterise SQL, never eval a JSON-y string from the wire.
Express is fast enough that the framework is rarely the bottleneck. The wins live in CPU cores used, downstream parallelism, payload size, and serialisation.
// either node's built-in cluster
const cluster = require('node:cluster');
const cpus = require('node:os').availableParallelism();
if (cluster.isPrimary) {
for (let i = 0; i < cpus; i++) cluster.fork();
} else {
require('./server');
}
// or PM2 in fork/cluster mode
// pm2 start server.js -i max --name api
In Kubernetes, prefer one process per pod — the orchestrator handles scaling. Cluster mode is for bare-metal / single-VM deploys.
// default: no compression in Express
// reverse proxy (nginx / cloudfront) does gzip/brotli — faster, async
// only enable compression() if you have no proxy:
// const compression = require('compression');
// app.use(compression({ threshold: 1024 }));
JSON.stringify a 1 MB object ≈ 8–15ms blocking. Stream large lists as NDJSONres.json twice (logging then returning) — hot path runs oncefast-json-stringify if you have a fixed schema and serialisation shows in profilesserver.keepAliveTimeout = 65_000 behind ALB / nginx (longer than upstream's idle)undici / shared http.AgentPromise.all or Promise.allSettled--inspect + Chrome DevTools or 0x flame graphs — intuition is wrong half the timep-limitMove the work: worker_threads for CPU, queues for I/O, WebAssembly for hot numeric loops. Don't try to hand-tune V8.
The orchestrator sends SIGTERM and waits up to terminationGracePeriodSeconds (default 30s in K8s). Without graceful shutdown, in-flight requests get ECONNRESET and clients see 5xx on every deploy.
// server.js
const server = app.listen(PORT, onListen);
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
log.info({ signal }, 'shutting down');
// 1) stop accepting new connections; finish in-flight
server.close((err) => { if (err) log.error({err}); });
// 2) tell the load balancer we're not ready
app.locals.ready = false;
// 3) drain workers + close DB after server closes
await worker.close(); // BullMQ
await db.destroy(); // Knex pool
await redis.quit();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// /healthz — "process is alive"; don't fail on dependency outages
app.get('/healthz', (_q, r) => r.json({ ok: true }));
// /readyz — "I should receive traffic"; gate by deps + shutdown
app.get('/readyz', async (_q, r) => {
if (shuttingDown || !app.locals.ready) return r.sendStatus(503);
try { await db.raw('select 1'); r.json({ ok: true }); }
catch { r.sendStatus(503); }
});
readyz to 503 first — LB stops sending new traffic within ~5sterminationGracePeriodSeconds > your slowest endpointpreStop hook of sleep 5 avoids the LB / readiness raceserver.headersTimeout / requestTimeout — cap how long an in-flight request can stall shutdownkeepAliveTimeout > load balancer's idle timeoutprocess.exit() on SIGTERM — you'll cut active sockets/healthz and /readyz — they answer different questionsIn production, Express is almost always behind a reverse proxy — nginx, Caddy, an ALB, Cloudflare. The proxy terminates TLS, handles compression, and rewrites headers. Express needs to trust the right ones.
// number = how many hops to trust X-Forwarded-For from
app.set('trust proxy', 1); // 1 proxy in front (LB)
// or specific subnets
app.set('trust proxy', 'loopback, 10.0.0.0/8');
// req.ip → client IP (X-Forwarded-For tail)
// req.protocol → "http" or "https" from X-Forwarded-Proto
// req.secure → true behind HTTPS-terminating proxy
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
client_max_body_size 10m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
proxy_buffering off; # for SSE
proxy_read_timeout 120s;
}
}
trust proxy → rate limits all share one IP, HTTPS detection wrongkeepAliveTimeout < proxy idle → 502 on every connection reuseTwo viable paths: code-first (write Express, generate spec) or spec-first (write OpenAPI, validate against it). Pick one per repo — mixing them is where contracts drift.
// zod-to-openapi or @asteasolutions/zod-to-openapi
import { OpenAPIRegistry, OpenApiGeneratorV3 }
from '@asteasolutions/zod-to-openapi';
const registry = new OpenAPIRegistry();
registry.register('User', UserSchema);
registry.registerPath({
method: 'get', path: '/users/{id}',
summary: 'Get a user by ID',
request: { params: z.object({ id: z.string().uuid() }) },
responses: {
200: { description: 'OK',
content: { 'application/json':
{ schema: UserSchema } } },
404: { description: 'Not found' },
}
});
const spec = new OpenApiGeneratorV3(registry.definitions)
.generateDocument({ openapi: '3.0.3', info, servers });
app.get('/openapi.json', (_q, r) => r.json(spec));
const OpenApiValidator =
require('express-openapi-validator');
app.use(OpenApiValidator.middleware({
apiSpec: './openapi.yaml',
validateRequests: true,
validateResponses: { coerceTypes: false },
}));
// requests get validated against the spec automatically;
// invalid ones → 400. Responses get validated in dev/CI —
// a route that drifts from the spec fails its tests.
info.version; bump on contract changeopenapi-typescript) — clients get types for freeThe Node web framework landscape grew up around Express; today there are stronger alternatives per axis. Most teams stay on Express because the ecosystem is the moat; some genuinely benefit from moving.
| Criterion | Express | Fastify | Koa | Hono | NestJS |
|---|---|---|---|---|---|
| Style | Middleware chain | Plugin / hook | Async middleware | Edge-first, web-std | Decorator, IoC |
| Speed | Solid | Fastest mainstream | ~Express | Excellent on edge | Slower (DI overhead) |
| Type safety | Manual | Built-in (JSON schema) | Manual | Excellent | Decorator-based |
| Schema | BYO (Zod / Joi) | Native JSON Schema | BYO | BYO (Zod nice) | BYO |
| Edge / Workers | No | Limited | No | First-class | No |
| Ecosystem | Largest | Growing | Smaller | Modern, smaller | Strong opinions |
| Learning curve | Lowest | Medium | Low | Low (web-std) | Steep (DI / modules) |
| Best for | Most apps | Throughput-critical APIs | Tiny middleware DAGs | Cloudflare / Bun / Deno | Large enterprise apps |
RequestA focused upgrade in a real codebase — the steps that actually break, in the order they break.
express to ^5.0.0npm dedupe, audit transitive Express 4 deps# the diff a codemod or sed pass handles
- app.del(...) app.delete(...)
- req.param('x') req.params.x ?? req.query.x ?? req.body.x
- res.send(404, body) res.status(404).send(body)
- res.redirect('/foo', 301) res.redirect(301, '/foo')
# remove these dependencies entirely
- body-parser
- express-async-errors
app.use('/x*', ...) → '/x{*splat}'RegExp literal or named paramrequire('express-async-errors')express-async-errors" still pass(err, req, res, next)req.ip changed semantics for some setups'simple'; if you used nested objects (?a[b]=c), explicitly set app.set('query parser', 'extended'){ limit: '128kb' } when switching from body-parser to express.json()Deploy to a single-instance canary; watch error rate & p99; promote after a clean 24 hours. Most regressions are routing-pattern bugs that surface only on real traffic.
req.body never reaches services/openapi.json, validated in CIRead your own access log every morning for a week. The slowest endpoints, the loudest 5xx, the strangest paths — you'll find more wins there than in any chapter of any book.