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.
| 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 callbacks → Poll (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 |
| 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) |
— |
| 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.ts → controllers/user.controller.ts → services/user.service.ts → repositories/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 | — |
| 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 |
| 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) |
— |