Node.js + Express.js

Mental Model: Node.js is the runtime — it gives JavaScript access to the OS (files, network, processes). Express.js is a thin layer on top that adds routing and middleware. Everything else — architecture, validation, auth, error handling, security — is your responsibility to build correctly. This freedom is Express's biggest strength and biggest trap.


Node.js Core — Runtime Fundamentals

Skill Core Concepts & Mental Model Key Techniques Tradeoffs & Failure Modes Resources
Event Loop Node.js is single-threaded but handles thousands of concurrent connections via non-blocking I/O. The event loop runs in phases: Timers (setTimeout/setInterval) → I/O callbacksPoll (wait for I/O) → Check (setImmediate) → Close callbacks. I/O is delegated to libuv thread pool (OS) — the JS thread never waits process.nextTick() (runs before next loop iteration — use sparingly), setImmediate() (runs in Check phase), queueMicrotask() (Promises — runs after current operation, before next phase) ❌ CPU-bound code (loops, crypto, image processing) blocks the event loop and stalls all concurrent requests. Never do heavy computation in a route handler — offload to worker_threads or a job queue Node.js Event Loop official, libuv docs
Modules — CJS vs ESM Two module systems exist. CommonJS (CJS): require()/module.exports — synchronous, dynamic. ES Modules (ESM): import/export — static, async, tree-shakeable. Node.js supports both but mixing them causes friction CJS: module.exports = { fn } / const { fn } = require('./module'). ESM: export const fn = ... / import { fn } from './module.js' (.js extension required). "type": "module" in package.json enables ESM globally ❌ Cannot require() an ESM module (use dynamic import() instead). Cannot use __dirname/__filename in ESM — use import.meta.url + fileURLToPath. Most npm packages are still CJS — check compatibility before switching project to ESM Node.js ESM docs
Async Patterns Three generations: callbacks → Promises → async/await. async/await is syntactic sugar over Promises — understand Promises to debug async/await. Node.js core APIs mostly use callbacks — promisify them util.promisify(fs.readFile), Promise.all() (parallel — all must succeed), Promise.allSettled() (parallel — don't fail-fast), Promise.race() (first to resolve/reject wins), Promise.any() (first to resolve wins) await inside a for loop = sequential (one at a time). Use Promise.all(array.map(async item => ...)) for parallel. Unhandled promise rejections crash the process in Node.js 15+ — always handle .catch() or use try/catch with await MDN Promises
Streams Process data piece-by-piece (chunks) without loading everything into memory. Four types: Readable (data source), Writable (data sink), Duplex (both), Transform (modify data in transit). Fundamental for file uploads, downloads, CSV processing, HTTP responses fs.createReadStream(), fs.createWriteStream(), .pipe(), stream.pipeline() (handles errors + cleanup — prefer over .pipe()), Transform stream for data transformation, readline for line-by-line file processing .pipe() doesn't propagate errors — use stream.pipeline() instead. Backpressure: if consumer is slower than producer, buffer fills up → memory leak. Check writable.write() return value — false means pause reading Node.js Streams, stream.pipeline
Worker Threads Run CPU-intensive JavaScript in a separate thread — doesn't block the event loop. Workers share ArrayBuffer (zero-copy via SharedArrayBuffer/Atomics) or communicate via postMessage (copy) const worker = new Worker('./worker.js'), worker.postMessage(data), parentPort.on('message', ...), workerData for initial data, thread pool pattern for reusing workers ❌ Worker threads are expensive to spawn — pool them (use workerpool or piscina library). Workers don't share memory by default (message passing = serialization overhead). Not for I/O-bound work — event loop handles that already Node.js Worker Threads, Piscina
Child Processes Run a separate process (shell command, Python script, another binary). Four methods: spawn (stream output), exec (buffer output), execFile (safer — no shell), fork (Node.js specific, IPC channel) spawn('ffmpeg', ['-i', input, output]) for long-running processes with streamed output. exec('ls -la', callback) for short commands. execFile to avoid shell injection. fork('./worker.js') for Node sub-processes with IPC exec() with user input = shell injection vulnerability — always use spawn/execFile with args array. exec() buffers all output in memory — use spawn for large output. Child process crash doesn't automatically crash parent — handle exit/error events Node.js Child Processes
File System (fs) Read/write files, directories, and file metadata. Two styles: callback-based (fs) and Promise-based (fs/promises). Always use fs/promises with async/await in modern code fs/promises: readFile, writeFile, appendFile, mkdir({ recursive: true }), readdir, stat, rename, unlink. Watch for changes: fs.watch(). Stream large files: createReadStream/createWriteStream fs.readFileSync() / fs.writeFileSync() block the event loop — never use in production request handlers (only CLI scripts). fs.watch() fires events twice on some OS (debounce it). Always handle ENOENT (file not found) and EACCES (permission denied) errors Node.js fs/promises
Environment & Config Never hardcode secrets or environment-specific values. Use process.env for config. Validate all env vars at startup — crash early if required vars are missing .env file + dotenv (local dev). process.env.NODE_ENV (development/production/test). Validate with Zod at startup: const config = z.object({ DATABASE_URL: z.string().url(), PORT: z.coerce.number() }).parse(process.env) ❌ Committing .env files to git = credential leak (use .gitignore). process.env values are always strings — parse numbers/booleans explicitly. No startup validation = mysterious runtime errors when env var is missing in production dotenv
Process Management Handle process lifecycle: graceful startup, graceful shutdown, signal handling, and crash recovery process.on('SIGTERM', gracefulShutdown), process.on('SIGINT', gracefulShutdown), process.on('unhandledRejection', (reason) => { logger.error(reason); process.exit(1) }), process.on('uncaughtException', ...) ❌ No uncaughtException handler = process crashes silently. No graceful shutdown = in-flight requests dropped when container restarts. uncaughtException should log + exit — not swallow the error. PM2/Docker will restart the process after crash Node.js Process

Express.js — Core

Skill Core Concepts & Mental Model Key Techniques Tradeoffs & Failure Modes Resources
Application Setup express() creates the app object. It is just a request handler function — you can pass it to http.createServer(app) or call app.listen(). Order matters: middleware and routes registered first are executed first const app = express(), app.use(express.json()) (parse JSON body), app.use(express.urlencoded({ extended: true })) (parse form data), app.use(cors()), app.use(helmet()), app.listen(PORT, () => console.log(...)) express.json() missing = req.body is undefined. Route order matters — a catch-all app.get('*', ...) before specific routes will swallow them. app.listen() returns an http.Server — save it for graceful shutdown Express.js Getting Started
Routing Map URL + HTTP method to a handler function. app.get/post/put/patch/delete. Express Router() creates mini-applications — group related routes and mount them on the main app app.get('/users/:id', handler), app.route('/users').get(list).post(create), express.Router() for modular routes, router.param('id', middleware) for param pre-processing, route parameters (req.params), query strings (req.query) app.get('/users/:id') will match before app.get('/users/me') if registered first — specific routes must come before parameterized ones. Missing return after res.send() = "headers already sent" error if code continues Express Routing
Middleware Functions that have access to req, res, and next. The entire Express request lifecycle is a middleware chain. Each middleware either ends the request (res.send()) or passes to the next (next()) Application-level: app.use(middleware). Router-level: router.use(middleware). Built-in: express.json(), express.static(). Third-party: cors, helmet, morgan. Error middleware: 4 params (err, req, res, next) — must be last ❌ Forgetting to call next() = request hangs forever. next(err) skips to error middleware. next('route') skips to next route. Middleware order is execution order — put helmet() and cors() first, error handler last Express Middleware Guide
Request Object (req) Represents the incoming HTTP request. Contains parsed body, URL params, query string, headers, cookies, and IP req.body (parsed body — needs express.json()), req.params (URL segments), req.query (query string), req.headers, req.method, req.path, req.ip (client IP — set trust proxy behind load balancer), req.cookies (needs cookie-parser) req.ip returns load balancer IP without app.set('trust proxy', 1). req.body is {} if Content-Type header is wrong. req.query values are always strings — parse numbers explicitly
Response Object (res) Represents the outgoing HTTP response. Use one send method per request — calling two throws "headers already sent" res.json(data), res.status(201).json(data), res.send(text), res.sendFile(path), res.redirect(url), res.set('Header', 'value'), res.cookie(name, value, options), res.download(path) (triggers browser download), streaming: readable.pipe(res) res.json() and res.send() both end the request — calling one after the other = "headers already sent" error. res.json() sets Content-Type: application/json automatically. Forgetting return res.json() = code continues executing after response is sent
Static Files Serve static assets (HTML, CSS, JS, images) directly from Express without a separate web server. For production, NGINX or a CDN should serve static files instead — much faster app.use(express.static('public')), app.use('/static', express.static(path.join(__dirname, 'public'))), set cache headers: express.static('public', { maxAge: '1d' }) express.static in production adds unnecessary Node.js overhead — use NGINX or upload to S3/Cloudfront. path.join(__dirname, 'public') is safer than relative path (varies by where process is started from)

Express.js — Request Lifecycle & Patterns

Skill Core Concepts & Mental Model Key Techniques Tradeoffs & Failure Modes Resources
Layered Architecture Structure Express apps in layers: Route (defines URL + method) → Controller (handles request/response, calls service) → Service (business logic, no HTTP knowledge) → Repository (data access, no business logic). Each layer has one responsibility routes/user.routes.tscontrollers/user.controller.tsservices/user.service.tsrepositories/user.repository.ts. Services are unit-testable without HTTP. Repositories are swappable (change DB without touching business logic) ❌ Putting DB queries in route handlers = untestable, unmaintainable. Putting req/res in the service layer = service is coupled to HTTP (can't reuse in a job or a CLI). Putting business logic in the repository = wrong layer Bulletproof Node.js
Middleware Patterns Common reusable middleware patterns: authentication, request logging, request ID injection, response time, input validation Auth middleware: const protect = async (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json(...); req.user = await verifyToken(token); next() }. Attach to specific routes or router-level ❌ Async middleware errors must be caught and passed to next(err) — uncaught async errors in middleware cause hanging requests in Express 4. Express 5 (now stable) auto-catches async errors. In Express 4: wrap async middleware with a catchAsync helper
catchAsync Helper Express 4 doesn't catch errors thrown from async route handlers. Every async handler needs try/catch or a wrapper — catchAsync DRYs this up const catchAsync = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next). Use: router.get('/users', catchAsync(async (req, res) => { ... })). Express 5 makes this unnecessary — async errors are caught automatically ❌ Forgetting catchAsync in Express 4 = unhandled async errors → request hangs or process crashes. Express 5 (npm install express@5) is now stable and fixes this natively
Input Validation Middleware Validate request body/params/query before the controller runs. Fail early — don't reach business logic with bad data Zod: const validate = (schema) => (req, res, next) => { const result = schema.safeParse(req.body); if (!result.success) return res.status(422).json({ error: result.error.flatten() }); req.body = result.data; next() }. Attach as route middleware: router.post('/users', validate(createUserSchema), createUser) ❌ Validating inside the controller mixes concerns. Not validating req.params and req.query (only req.body) = injection via URL parameters. Using parse() instead of safeParse() throws ZodError — catch it in error middleware
Global Error Handler Centralized error handling. All errors (thrown or next(err)) flow to a single 4-param middleware. Never scatter error responses `app.use((err, req, res, next) => { const status = err.statusCode 500; const message = err.isOperational ? err.message : 'Internal server error'; logger.error({ err, requestId: req.id }); res.status(status).json({ success: false, error: { code: err.code, message } }) }). Custom AppErrorclass:class AppError extends Error { constructor(message, statusCode, code) { super(message); this.statusCode = statusCode; this.isOperational = true } }`
Request ID & Correlation Attach a unique ID to every request for tracing logs across the request lifecycle. Essential in production npm install uuid or crypto.randomUUID(). Middleware: app.use((req, res, next) => { req.id = crypto.randomUUID(); res.set('X-Request-Id', req.id); next() }). Pass req.id to every logger call ❌ Without request IDs, correlating logs from the same request across a service is nearly impossible. Double-check that X-Request-Id header from clients is sanitized before trusting it (don't let users inject fake IDs)
Route Organization (Feature-based) Organize routes by feature, not by HTTP verb. Co-locate route, controller, service, repository for the same feature src/features/users/users.routes.ts, users.controller.ts, users.service.ts, users.repository.ts. Mount in main app: app.use('/api/v1/users', usersRouter). Barrel export per feature ✅ Scales to large teams — each feature is independently navigable ❌ Barrel files can hurt tree-shaking — keep them thin. Don't nest features too deeply

Express.js — Security

Skill Core Concepts & Mental Model Key Techniques Tradeoffs & Failure Modes Resources
Helmet Sets secure HTTP response headers in one line. Protects against XSS, clickjacking, MIME sniffing, and more. Should be the first middleware in every Express app app.use(helmet()) — enables: Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Strict-Transport-Security (HSTS), Referrer-Policy, Permissions-Policy. Customize: helmet({ contentSecurityPolicy: { directives: { ... } } }) ❌ Default Helmet CSP may break CDN scripts / inline scripts — configure contentSecurityPolicy.directives explicitly. Helmet only sets headers — it doesn't prevent all attacks. HSTS with includeSubDomains bricks misconfigured subdomains Helmet.js
CORS Controls which origins can make cross-origin requests to your API. Must be configured correctly — too permissive = security risk, too strict = broken frontend app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(','), credentials: true, methods: ['GET','POST','PUT','PATCH','DELETE'], allowedHeaders: ['Content-Type', 'Authorization'] })). Whitelist specific origins in prod — never origin: '*' with credentials: true Access-Control-Allow-Origin: * with credentials: true = browser rejects it (spec violation). CORS is a browser protection — server-to-server requests ignore it. OPTIONS preflight requests must return 204 quickly cors npm
Rate Limiting Prevent brute-force attacks, API abuse, and accidental request floods per IP or per user express-rate-limit: app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, message: 'Too many attempts' })). For distributed (multi-server): express-rate-limit + rate-limit-redis store ❌ In-memory rate limiter (default) doesn't work across multiple Node instances — use Redis store. Rate-limit auth routes aggressively, API routes moderately. Return Retry-After header. 429 Too Many Requests status code express-rate-limit
SQL Injection Prevention Never build SQL queries by string concatenation with user input. Use parameterized queries or ORM — always ✅ Safe: db.query('SELECT * FROM users WHERE id = $1', [userId]). ❌ Unsafe: db.query('SELECT * FROM users WHERE id = ' + userId). With ORM: Prisma/Drizzle parameterize automatically. For raw SQL: always use $1/? placeholders ❌ String template literals in SQL = injection. SELECT * FROM ${table} where table is user input = catastrophic. Never trust req.params, req.query, req.body for SQL construction OWASP SQL Injection
Security Best Practices A checklist of Express-specific security settings Disable X-Powered-By: app.disable('x-powered-by') (hides "Express"). Validate Content-Type. Use express.json({ limit: '10kb' }) to prevent large payload attacks. hpp middleware to prevent HTTP parameter pollution. express-mongo-sanitize if using MongoDB ❌ Default X-Powered-By: Express header reveals your stack to attackers. No request body size limit = DoS via large payloads. Missing Content-Type check = unexpected body parsing Express Security Best Practices

Express.js — Performance & Production

Skill Core Concepts & Mental Model Key Techniques Tradeoffs & Failure Modes Resources
Compression Compress HTTP responses with gzip/Brotli — reduces transfer size significantly for text-based responses (JSON, HTML, CSS, JS) compression middleware: app.use(compression({ threshold: 1024 })) (only compress responses > 1KB). For Brotli (better compression): use NGINX or Cloudflare — Node.js Brotli is slow. Skip compression for already-compressed assets (images, video) ❌ Compressing small responses (< 1KB) wastes CPU with no meaningful gain. Compressing binary assets (images already in WebP/AVIF) = wasted CPU. For production, NGINX handles compression more efficiently than Node.js compression npm
Logging — Structured Production logging must be structured (JSON), not console.log. Structured logs are queryable in log aggregators (Datadog, CloudWatch, Loki) Pino (fastest JSON logger for Node.js): `const logger = pino({ level: process.env.LOG_LEVEL 'info' }). HTTP request logging: pino-httpmiddleware. Log levels:trace<debug<info<warn<error<fatal. Always include requestId, userId, duration`
Health Check Endpoint A dedicated endpoint that verifies the app and its dependencies (DB, Redis, etc.) are healthy. Required for load balancers and container orchestration (Kubernetes liveness/readiness probes) app.get('/health', async (req, res) => { const dbOk = await checkDb(); const redisOk = await checkRedis(); const status = dbOk && redisOk ? 200 : 503; res.status(status).json({ status: status === 200 ? 'ok' : 'degraded', db: dbOk, redis: redisOk, uptime: process.uptime() }) }) ❌ Health check that always returns 200 = useless (load balancer sends traffic to broken instance). Health check that does heavy work = slows down the check itself. Separate liveness (is process alive?) from readiness (is it ready to serve traffic?)
Graceful Shutdown On SIGTERM/SIGINT, stop accepting new requests, finish in-flight requests, close DB connections, then exit. Without this, Docker/Kubernetes container restarts drop active requests const server = app.listen(PORT). On signal: server.close(() => { db.$disconnect(); redis.quit(); process.exit(0) }). process.on('SIGTERM', shutdown). Set a timeout: if shutdown takes > 10s, force-exit (process.exit(1)) ❌ No graceful shutdown = Kubernetes pod restart drops active HTTP requests mid-response. process.exit(0) immediately without closing DB = connection pool leak. Kubernetes sends SIGTERM before killing — handle it Node.js Graceful Shutdown
Cluster Mode Fork one Node.js process per CPU core. Each process runs the full Express app independently and handles requests in parallel cluster module (manual): if (cluster.isPrimary) { for (let i = 0; i < os.cpus().length; i++) cluster.fork() }. PM2 (recommended): pm2 start app.js --instances max. Zero-downtime reload: pm2 reload app (rolling restart — not restart) ❌ Clustered processes don't share memory — in-memory state (sessions, cache, rate limit counters) is per-process. Use Redis for shared state. pm2 restart kills all processes simultaneously (downtime). pm2 reload does rolling restart (zero downtime) PM2 Cluster
Response Caching Cache expensive route handler responses. Two levels: in-process memory (single instance, fast) or Redis (distributed, shared across instances) In-memory: node-cache or lru-cache. Redis cache: check Redis → hit (return cached) → miss (run handler, store in Redis with TTL, return). Cache key = ${method}:${path}:${JSON.stringify(params)}. Cache-control headers for CDN/browser caching ❌ Caching user-specific data without userId in cache key = data leak. Stale cache after DB mutation = users see old data (use revalidatePath or TTL). In-memory cache per cluster process = 8 separate caches (inconsistent)
Environment-based Config Centralize all config in one validated module. Rest of the app imports from config, never from process.env directly config/index.ts: export const config = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']), PORT: z.coerce.number().default(3000), DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), REDIS_URL: z.string().url() }).parse(process.env). Crash at startup if validation fails ❌ Scattered process.env.WHATEVER across the codebase = impossible to audit what config the app needs. No Zod validation = runtime errors from missing env vars in production. Never use dotenv in production — use proper secret management (AWS Secrets Manager, Doppler)