Node.js
100 Questions & Answers with Code Examples
Node.js embeds the V8 JavaScript engine and adds a set of C++ bindings for OS features: file system, networking, timers, and process control. Unlike browsers, there is no DOM, no window, no alert — but there is process, Buffer, and native streams. Node.js is designed for server-side I/O, not UI rendering.
// Available in Node, not in browsers
console.log(process.version); // 'v20.11.0'
console.log(process.platform); // 'linux' | 'win32' | 'darwin'
console.log(__dirname); // current directory path
console.log(Buffer.from('hello').toString('hex')); // '68656c6c6f'
// NOT available in Node
// document.querySelector('#app') → ReferenceError
// window.localStorage → ReferenceErrorNode.js is event-driven and non-blocking. When an I/O operation (DB query, file read, HTTP call) is started, Node.js registers a callback and immediately moves on to the next event. When the OS signals completion, the callback is queued for execution. No thread is ever blocked waiting — one thread can serve thousands of concurrent I/O-bound clients.
const http = require('http');
// Single thread handles all concurrent connections
http.createServer(async (req, res) => {
// While waiting for DB, the thread serves other requests
const data = await db.query('SELECT * FROM orders WHERE id = ?', [req.params.id]);
res.end(JSON.stringify(data));
}).listen(3000);
// 10,000 concurrent requests: thread stays free during each DB wait
// A Java/PHP thread-per-request model would need 10,000 threadsThe event loop cycles through phases: timers runs setTimeout/setInterval callbacks whose delay has passed; pending I/O runs deferred I/O callbacks; poll retrieves new I/O events and blocks if the queue is empty; check runs setImmediate callbacks; close fires close events. Between each phase, microtasks (Promise.then, process.nextTick) drain completely.
setTimeout(() => console.log('timeout'), 0); // timers phase
setImmediate(() => console.log('immediate')); // check phase
process.nextTick(() => console.log('nextTick')); // microtask — before any phase
Promise.resolve().then(() => console.log('promise')); // microtask
// Output: nextTick → promise → timeout → immediate
// nextTick and Promises drain before the event loop moves to any phaseSome operations (file system, DNS lookup, crypto) use libuv’s default thread pool of 4 threads. The main JS thread dispatches the work and registers a callback; a worker thread executes the blocking syscall; when done it pushes the result back to the event loop. Increase the pool with UV_THREADPOOL_SIZE for CPU/disk-heavy workloads.
// fs.readFile uses the thread pool internally
const fs = require('fs/promises');
// These run on thread pool workers — main thread stays free
const [a, b, c, d] = await Promise.all([
fs.readFile('a.txt', 'utf8'),
fs.readFile('b.txt', 'utf8'),
fs.readFile('c.txt', 'utf8'),
fs.readFile('d.txt', 'utf8')
]);
// Increase pool for CPU-heavy crypto or heavy parallel file work:
// UV_THREADPOOL_SIZE=8 node server.jsI/O-bound work (network, disk) releases the thread while waiting — Node.js shines here. CPU-bound work (image resizing, compression, math) keeps the thread busy, blocking all other requests. Solutions: Worker Threads (run JS in parallel threads), child processes, or offloading to a queue processed by separate services.
// ❌ CPU-bound on main thread — blocks ALL requests for ~2 seconds
app.get('/slow', (req, res) => {
const result = heavyCryptoOperation(); // blocks event loop
res.json({ result });
});
// ✅ Offload to Worker Thread
const { Worker } = require('worker_threads');
app.get('/fast', (req, res) => {
const worker = new Worker('./heavyWork.js', { workerData: req.query });
worker.once('message', result => res.json({ result }));
worker.once('error', err => res.status(500).json({ error: err.message }));
});process is a global object exposing environment variables, command-line arguments, memory usage, and exit control. Listening to SIGTERM/SIGINT enables graceful shutdown — finish in-flight requests, close DB connections, then exit cleanly instead of being killed mid-transaction.
console.log(process.env.NODE_ENV); // 'production'
console.log(process.argv); // ['node', 'server.js', '--port=3000']
console.log(process.memoryUsage().heapUsed / 1024 / 1024, 'MB');
// Graceful shutdown
const server = app.listen(3000);
function shutdown(signal) {
console.log(`${signal} received — shutting down gracefully`);
server.close(async () => { // stop accepting new connections
await db.end(); // close DB pool
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000); // force-kill after 10s
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));process.nextTick() queues a callback to run before the event loop moves to the next phase — even before Promises. Promise.then() queues a microtask that runs after nextTick callbacks. setImmediate() runs in the check phase of the next loop iteration — after I/O callbacks.
setImmediate(() => console.log('4 — setImmediate'));
setTimeout(() => console.log('3 — setTimeout 0'), 0);
Promise.resolve().then(() => console.log('2 — Promise'));
process.nextTick(()=> console.log('1 — nextTick'));
console.log('0 — synchronous');
// Output: 0 → 1 → 2 → 3 → 4
// Use nextTick to emit events after constructor returns:
class MyEmitter extends EventEmitter {
constructor() {
super();
process.nextTick(() => this.emit('ready')); // listener attached by then
}
}Different projects require different Node.js versions. nvm (Unix) and fnm (cross-platform, faster) install multiple versions side-by-side and switch between them per-shell or per-project via a .nvmrc file. Never install Node.js globally via a system package manager on a dev machine — you’ll hit permission issues and version lock-in.
# Install fnm (cross-platform)
# winget install Schniz.fnm
# Install and use a specific version
fnm install 20
fnm use 20
# Project-level pin via .nvmrc
echo "20" > .nvmrc
fnm use # reads .nvmrc automatically
# Verify
node --version # v20.x.x
npm --version # 10.x.xCommonJS (require) is synchronous, evaluated at runtime, and returns the cached exports object. ESM (import) is static, tree-shakeable, evaluated asynchronously, and supports top-level await. Use ESM for new projects (set "type":"module" in package.json). Mixing the two requires interop care — ESM can import CJS but CJS cannot require() ESM.
// CommonJS (.js with no "type":"module" in package.json)
const fs = require('fs');
const { add } = require('./math'); // destructure after require
module.exports = { myFn };
// ESM (.mjs OR "type":"module" in package.json)
import fs from 'fs';
import { add } from './math.js'; // must include .js extension
import data from './config.json' assert { type: 'json' };
export { myFn };
export default class MyClass {}
// Top-level await (ESM only)
const config = await fetch('/config').then(r => r.json());The first time a module is require()d, Node.js loads, compiles, and executes it, then stores the result in require.cache keyed by file path. Subsequent require() calls return the cached exports object directly — the file is read and executed only once per process. This is why singletons (DB connections, config) work reliably.
// db.js — connection created only once
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
module.exports = pool; // cached after first require()
// app.js
const db = require('./db'); // creates pool
// routes/users.js
const db = require('./db'); // returns same pool object from cache
// Inspect/clear cache (useful in tests)
console.log(Object.keys(require.cache).length);
delete require.cache[require.resolve('./db')]; // force fresh loadpackage.json is the project manifest. dependencies are required at runtime; devDependencies are only for development/build. scripts are shell commands run via npm run. engines pins the required Node version. main or exports defines the public entry point.
{
"name": "my-api",
"version": "1.0.0",
"type": "module",
"engines": { "node": ">=20" },
"main": "./src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "jest --coverage",
"lint": "eslint src",
"build": "tsc -p tsconfig.json"
},
"dependencies": { "express": "^4.18.2", "pg": "^8.11.0" },
"devDependencies": { "jest": "^29.7.0", "eslint": "^8.57.0" }
}SemVer uses MAJOR.MINOR.PATCH. ^4.18.2 (caret) allows any compatible version ≥4.18.2 and <5.0.0. ~4.18.2 (tilde) allows only patch updates ≥4.18.2 and <4.19.0. Exact 4.18.2 pins precisely. package-lock.json records the exact tree installed — always commit it to ensure reproducible builds.
# Install and save to dependencies
npm install express # latest, adds ^x.y.z
npm install express@4.18.2 # exact version
# Development-only (not shipped to production)
npm install --save-dev jest eslint
# Check for outdated packages
npm outdated
# Upgrade within semver range
npm update express
# Upgrade beyond range (with care)
npm install express@latestnpm scripts run in a shell where ./node_modules/.bin is prepended to PATH — so local package CLIs (jest, eslint, tsc) work without global install. Scripts chain with && (sequential), & (parallel), or npm-run-all for cross-platform support. Pre/post hooks (prestart, postbuild) run automatically.
{
"scripts": {
"prebuild": "rimraf dist",
"build": "tsc && cp -r public dist/",
"postbuild":"echo Build complete",
"dev": "nodemon --watch src --ext ts --exec ts-node src/index.ts",
"test:unit":"jest --testPathPattern=unit",
"test:e2e": "jest --testPathPattern=e2e",
"test": "npm run test:unit && npm run test:e2e",
"lint:fix": "eslint src --fix"
}
}In CommonJS, __dirname and __filename are injected by the module wrapper. In ESM they don’t exist — use import.meta.url and the fileURLToPath helper to derive them. This trips up developers migrating to ESM who rely on path-relative file loading.
// CommonJS
const path = require('path');
const abs = path.join(__dirname, 'templates', 'email.html');
// ESM equivalent
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const abs = join(__dirname, 'templates', 'email.html');
// Or use import.meta.resolve() for module-relative paths
const templateUrl = new URL('./templates/email.html', import.meta.url);When you require('express'), Node.js walks up the directory tree from the current file looking for a node_modules/express folder at each level. It stops at the first match. This allows nested dependencies to have their own versions while sharing a top-level copy when versions are compatible.
// Require resolution order for require('express') from /app/src/routes/users.js:
// 1. /app/src/routes/node_modules/express
// 2. /app/src/node_modules/express
// 3. /app/node_modules/express ← found here
// 4. /node_modules/express
// 5. throw MODULE_NOT_FOUND
// Relative require — always explicit file
const helper = require('./helpers/format'); // looks for format.js, format/index.js
// Absolute paths via baseUrl aliases (TypeScript / webpack)
// tsconfig.json: { "baseUrl": "src" }
// import { db } from 'db/client'; → src/db/client.tsnpm workspaces hoist shared dependencies to the root node_modules and symlink each workspace package there. A single npm install at the root installs everything. Scripts run across all workspaces with npm run build --workspaces. This avoids duplicated installations and enables cross-package imports during development.
// Root package.json
{
"name": "my-monorepo",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}
// packages/shared-utils/package.json
// { "name": "@myrepo/shared-utils", "version": "1.0.0" }
// apps/api/package.json — import from another workspace
// { "dependencies": { "@myrepo/shared-utils": "*" } }fs.readFileSync blocks the event loop until the file is fully loaded — only acceptable in startup scripts. fs.readFile / fs.promises.readFile reads the whole file asynchronously into memory. fs.createReadStream reads in chunks, ideal for large files that would exhaust RAM if fully buffered.
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
// Sync — blocks event loop (startup only)
const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
// Async — good for small files
const text = await fsp.readFile('readme.md', 'utf8');
// Stream — correct for large files
const readable = fs.createReadStream('bigfile.csv', { encoding: 'utf8' });
readable.on('data', chunk => process.stdout.write(chunk));
readable.on('end', () => console.log('Done'));
readable.on('error', err => console.error(err));Windows uses backslash (\), Unix uses forward slash (/). Hard-coding either breaks on the other platform. The path module uses the correct separator for the current OS and handles edge cases like double slashes, trailing slashes, and relative components (..).
const path = require('path');
path.join('/users', 'alice', 'docs', 'file.txt');
// Unix: '/users/alice/docs/file.txt'
// Windows: '\\users\\alice\\docs\\file.txt'
path.resolve('src', '../config', 'app.json');
// Resolves to absolute path from cwd
path.extname('image.upload.png'); // '.png'
path.basename('/foo/bar/file.js'); // 'file.js'
path.dirname('/foo/bar/file.js'); // '/foo/bar'
path.parse('/foo/bar/file.js');
// { root:'/', dir:'/foo/bar', base:'file.js', ext:'.js', name:'file' }The built-in http module provides low-level HTTP parsing. createServer accepts a callback fired for every request. The callback receives an IncomingMessage (readable stream) and a ServerResponse (writable stream). Most production code uses Express/Fastify on top — but understanding the raw layer helps debug edge cases.
const http = require('http');
const server = http.createServer((req, res) => {
// Parse body manually
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
const data = req.headers['content-type']?.includes('json')
? JSON.parse(body) : body;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ method: req.method, url: req.url, body: data }));
});
});
server.listen(3000, () => console.log('http://localhost:3000'));EventEmitter is the backbone of Node.js asynchronous patterns — streams, HTTP servers, and process signals all extend it. .on(event, listener) registers a listener; .emit(event, ...args) fires all listeners synchronously. .once() removes the listener after the first call. Memory leak warnings fire when >10 listeners are added to one event.
const { EventEmitter } = require('events');
class OrderService extends EventEmitter {
async placeOrder(order) {
const saved = await db.insert(order);
this.emit('order:placed', saved); // notify all listeners
return saved;
}
}
const orders = new OrderService();
orders.on('order:placed', order => emailService.sendConfirmation(order));
orders.on('order:placed', order => inventoryService.reserve(order));
orders.on('order:placed', order => analyticsService.track('order', order));
// Remove specific listener
const logger = order => console.log('Order:', order.id);
orders.on('order:placed', logger);
orders.off('order:placed', logger); // cleanupThe built-in crypto module wraps OpenSSL. Use it for password hashing (prefer bcrypt for that), HMAC signatures for webhooks, generating secure random tokens for password resets, and AES encryption for sensitive data at rest. Always use crypto.timingSafeEqual when comparing secrets to prevent timing attacks.
const crypto = require('crypto');
// SHA-256 hash (not for passwords!)
const hash = crypto.createHash('sha256').update('data').digest('hex');
// HMAC — verify webhook payloads
const sig = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(rawBody).digest('hex');
const safe = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(receivedSig));
// Secure random token (email verification, password reset)
const token = crypto.randomBytes(32).toString('hex'); // 64-char hex string
// UUID v4
const uuid = crypto.randomUUID(); // 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'exec runs a shell command and buffers output. spawn streams output and is better for large output or long-running processes. execFile runs a binary directly without a shell (safer). fork spawns a Node.js child process with an IPC channel for message passing.
const { exec, spawn, execFile } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
// exec — buffer output (not for large output)
const { stdout } = await execAsync('git log --oneline -5');
console.log(stdout);
// spawn — stream output (git clone, ffmpeg, etc.)
const child = spawn('ffmpeg', ['-i', 'in.mp4', 'out.webm']);
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
child.on('close', code => console.log(`Exit: ${code}`));
// execFile — safer, no shell injection risk
await promisify(execFile)('node', ['--version']);util.promisify wraps a function that follows the Node.js error-first callback convention ((err, result) => {}) and returns a function that returns a Promise. This bridges the gap between older Node.js core APIs and modern async/await code without rewriting the underlying library.
const { promisify } = require('util');
const fs = require('fs');
// Old callback style
fs.readFile('data.json', 'utf8', (err, data) => {
if (err) return console.error(err);
console.log(JSON.parse(data));
});
// Promisified
const readFile = promisify(fs.readFile);
const data = await readFile('data.json', 'utf8');
// Or use the built-in promises API (Node 10+)
const { readFile: readFileP } = require('fs/promises');
const data2 = await readFileP('data.json', 'utf8');
// Custom promisify symbol for non-standard callbacks
myLib[util.promisify.custom] = (arg) => new Promise(...);The os module provides CPU count (for clustering), total/free memory, hostname, platform, and temp directory. Use it in startup health checks, to auto-scale worker counts, and to build environment-aware log metadata.
const os = require('os');
console.log(os.cpus().length); // 8 — use for cluster fork count
console.log(os.totalmem() / 1e9, 'GB');
console.log(os.freemem() / 1e9, 'GB');
console.log(os.hostname()); // 'web-pod-1'
console.log(os.platform()); // 'linux'
console.log(os.tmpdir()); // '/tmp'
// Log context for every request
const meta = {
host: os.hostname(),
pid: process.pid,
platform: os.platform(),
nodeVer: process.version
};Each async operation required a callback; if the next step depended on the result, it nested deeper. Three dependent calls produce three indentation levels; error handling required per-callback checks. Promises linearize the chain; async/await makes it read synchronously while remaining non-blocking.
// ❌ Callback hell — "pyramid of doom"
getUser(id, (err, user) => {
if (err) return cb(err);
getOrders(user.id, (err, orders) => {
if (err) return cb(err);
getInvoices(orders[0].id, (err, inv) => {
if (err) return cb(err);
cb(null, inv);
});
});
});
// ✅ async/await — flat, readable, one try/catch
async function loadData(id) {
const user = await getUser(id);
const orders = await getOrders(user.id);
const inv = await getInvoices(orders[0].id);
return inv;
}Since Node 15, an unhandled Promise rejection exits the process with a non-zero code. Older versions logged a warning and continued — silently swallowing errors. Always: await inside try/catch, add .catch() to floating Promises, and register a global handler as a last-resort safety net.
// ❌ Floating Promise — rejection never caught
sendEmail(user); // returns Promise, no await, no .catch()
// ✅ Always await or chain .catch()
await sendEmail(user);
sendEmail(user).catch(err => logger.error('Email failed', err));
// Global safety net — catch any remaining unhandled rejections
process.on('unhandledRejection', (reason, promise) => {
logger.fatal({ err: reason, promise }, 'Unhandled rejection — shutting down');
server.close(() => process.exit(1));
});
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'Uncaught exception — shutting down');
process.exit(1);
});Streams emit chunks of data as they become available. A Readable produces chunks; a Writable consumes them. Piping connects them — data flows from source to destination without buffering the entire payload. Processing a 10 GB CSV file uses the same ~50 MB of working memory whether the file is 1 MB or 100 GB.
const fs = require('fs');
const zlib = require('zlib');
// Compress a large file — constant memory, any file size
fs.createReadStream('large.csv')
.pipe(zlib.createGzip())
.pipe(fs.createWriteStream('large.csv.gz'));
// Stream HTTP response to client — no full buffering
app.get('/download', (req, res) => {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="data.csv"');
db.createQueryStream('SELECT * FROM orders')
.pipe(csvTransform)
.pipe(res);
});When a writable stream’s buffer is full, write() returns false — the signal to pause the readable. When the writable drains, it emits 'drain' — resume the readable. pipe() handles this automatically. Ignoring backpressure causes unbounded memory growth: the buffer fills faster than it empties.
// pipe() manages backpressure automatically — prefer this
readable.pipe(writable);
// Manual backpressure (for custom logic between read and write)
function copyWithBackpressure(readable, writable) {
readable.on('data', chunk => {
const canContinue = writable.write(chunk);
if (!canContinue) {
readable.pause(); // buffer full — stop reading
writable.once('drain', () => readable.resume()); // buffer empty — continue
}
});
readable.on('end', () => writable.end());
}Worker Threads run in separate V8 instances with their own event loop. Communication uses message passing (postMessage) or shared SharedArrayBuffer. Unlike child_process.fork, workers share the same process memory space, making them faster to spawn and more memory-efficient for parallel computation tasks.
// worker.js
const { workerData, parentPort } = require('worker_threads');
const result = heavyComputation(workerData.input);
parentPort.postMessage({ result });
// main.js
const { Worker } = require('worker_threads');
function runWorker(input) {
return new Promise((resolve, reject) => {
const w = new Worker('./worker.js', { workerData: { input } });
w.once('message', resolve);
w.once('error', reject);
w.once('exit', code => { if (code !== 0) reject(new Error(`Exit ${code}`)); });
});
}
app.get('/compute', async (req, res) => {
const result = await runWorker(req.query.data);
res.json({ result });
});AsyncLocalStorage stores a value that is automatically available to all async operations within a given context — without passing it through every function call. Ideal for request-scoped data like correlation IDs, user info, and database transactions that need to be accessible deep in the call stack.
const { AsyncLocalStorage } = require('async_hooks');
const requestContext = new AsyncLocalStorage();
// Middleware — set context at request entry
app.use((req, res, next) => {
const store = { requestId: req.headers['x-request-id'] || crypto.randomUUID(),
userId: req.user?.id };
requestContext.run(store, next); // all async ops in this request see the store
});
// Anywhere in the call chain — no prop drilling
function logError(err) {
const { requestId, userId } = requestContext.getStore() ?? {};
logger.error({ err, requestId, userId }, 'Error occurred');
}Middleware functions are called in registration order with (req, res, next). Calling next() passes control to the next middleware; calling next(err) jumps to error-handling middleware; not calling either ends the cycle (request hangs unless res is finished). Global middleware applies to all routes; router-level middleware applies to a subset.
const express = require('express');
const app = express();
// Global middleware
app.use(express.json()); // parse JSON bodies
app.use((req, res, next) => {
req.startTime = Date.now();
next(); // pass to next middleware
});
// Route-specific middleware
app.get('/protected', authenticate, (req, res) => {
res.json({ user: req.user });
});
// Error-handling middleware — MUST have 4 params
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({ error: err.message });
});express.Router() creates a mini-application with its own middleware stack and routes. Mount it on the main app with a prefix. This keeps each feature’s routes in one file — routes/orders.js handles all /orders/* paths — without the main app.js growing unbounded.
// routes/orders.js
const router = require('express').Router();
router.get('/', listOrders);
router.post('/', createOrder);
router.get('/:id', getOrder);
router.patch('/:id', updateOrder);
router.delete('/:id', deleteOrder);
module.exports = router;
// app.js
const ordersRouter = require('./routes/orders');
const productsRouter = require('./routes/products');
app.use('/api/orders', ordersRouter); // GET /api/orders/:id → getOrder
app.use('/api/products', productsRouter);Named segments prefixed with : in the route path are parsed into req.params. Query string values after ? are parsed into req.query. Both are strings — always coerce or validate types before using them in DB queries.
// Route: GET /api/orders/:orderId/items/:itemId?format=json&include=product
app.get('/api/orders/:orderId/items/:itemId', async (req, res) => {
const orderId = parseInt(req.params.orderId, 10); // string → number
const itemId = parseInt(req.params.itemId, 10);
const format = req.query.format ?? 'json'; // default value
const include = req.query.include?.split(',') ?? []; // comma-separated list
if (isNaN(orderId)) return res.status(400).json({ error: 'Invalid orderId' });
const item = await orderService.getItem(orderId, itemId, include);
res.json(item);
});Express 4 does not catch async errors automatically — an unhandled Promise rejection in a route bypasses the error middleware. Wrap async handlers with a helper that catches rejections and calls next(err). Express 5 (in beta) handles this natively.
// Helper — wrap async route handlers
const asyncWrap = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
// Usage — thrown errors reach error middleware
app.get('/orders/:id', asyncWrap(async (req, res) => {
const order = await db.orders.findByPk(req.params.id);
if (!order) throw Object.assign(new Error('Not found'), { status: 404 });
res.json(order);
}));
// Centralized error handler
app.use((err, req, res, next) => {
logger.error({ err, url: req.url }, 'Request error');
res.status(err.status || 500).json({ error: err.message });
});express-validator decorates routes with validator chains. Call validationResult(req) in the handler and return early if errors exist. Sanitizers (.trim(), .escape()) normalize input before validation. This stops injection payloads, oversized fields, and type mismatches before any DB call.
const { body, param, validationResult } = require('express-validator');
const createUserRules = [
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }).withMessage('Min 8 characters'),
body('age').optional().isInt({ min: 13, max: 120 }).toInt(),
body('name').trim().notEmpty().escape()
];
app.post('/users', createUserRules, (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty())
return res.status(422).json({ errors: errors.array() });
const { email, password, age, name } = req.body; // already sanitized
userService.create({ email, password, age, name });
});The cors package sets the correct Access-Control-* headers. Configure it with an allowlist of trusted origins rather than * for authenticated APIs. Pass different options per-route for fine-grained control — e.g., a public read endpoint vs a private write endpoint.
const cors = require('cors');
const corsOptions = {
origin: ['https://app.mysite.com', 'https://admin.mysite.com'],
methods: ['GET','POST','PUT','PATCH','DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // allow cookies / auth headers cross-origin
maxAge: 86400 // cache preflight for 24h
};
app.use(cors(corsOptions)); // global
// Public GET — allow all origins
app.get('/api/public', cors(), getPublicData);
// Private write — strict origins
app.post('/api/orders', cors(corsOptions), authenticate, createOrder);express-rate-limit tracks requests per IP (or per user) in a window and rejects excess requests with 429. Use a stricter limit on auth endpoints (login, password-reset) and a looser limit on general API routes. For distributed deployments, use a Redis store so limits are shared across all pods.
const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' }
});
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // 10 login attempts per hour
skipSuccessfulRequests: true
});
app.use('/api/', apiLimiter);
app.post('/api/auth/login', authLimiter, loginHandler);The compression package gzip/deflate-compresses response bodies when the client sends Accept-Encoding: gzip. JSON API responses compress 5–10×. Skip compression for already-compressed formats (JPEG, PNG, video) using a filter function. For high-throughput APIs, offload compression to nginx instead.
const compression = require('compression');
app.use(compression({
level: 6, // 1 (fast) to 9 (best ratio), 6 is a good default
threshold: 1024, // don't compress responses smaller than 1KB
filter: (req, res) => {
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
// Automatically skips image/jpeg, image/png, etc.
}
}));Mounting versioned routers at /api/v1 and /api/v2 lets old clients continue using v1 while new clients adopt v2. The two versions co-exist in the same process, sharing infrastructure (DB, auth) while diverging in routes and response shapes. Deprecate v1 with a sunset header before removing it.
const v1 = require('./routes/v1');
const v2 = require('./routes/v2');
app.use('/api/v1', v1);
app.use('/api/v2', v2);
// Deprecation middleware for v1
app.use('/api/v1', (req, res, next) => {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 31 Dec 2025 00:00:00 GMT');
res.setHeader('Link', '</api/v2>; rel="successor-version"');
next();
});helmet sets 15+ security-related HTTP headers in one line: Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security, Referrer-Policy, and more. Add it as the very first middleware so headers are set regardless of which route handles the request.
const helmet = require('helmet');
app.use(helmet()); // sane defaults — add this first
// Customise CSP for a React SPA serving from the same Express app
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'nonce-{random}'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https://cdn.myapp.com']
}
},
crossOriginEmbedderPolicy: false // disable for embedded iframes
}));REST maps nouns (resources) to URLs and uses HTTP verbs to express intent. GET is safe and idempotent; PUT replaces a resource; PATCH partially updates; DELETE removes. URLs should be lowercase plural nouns, never verbs (/orders not /getOrders). Nested resources for owned sub-resources: /orders/123/items.
GET /api/orders → list orders (with filter/page params)
POST /api/orders → create order (body: order data)
GET /api/orders/:id → get single order
PUT /api/orders/:id → replace full order
PATCH /api/orders/:id → partial update
DELETE /api/orders/:id → delete order
GET /api/orders/:id/items → list items in order
POST /api/orders/:id/items → add item to order
# Non-CRUD actions — use verbs as sub-resources
POST /api/orders/:id/cancel
POST /api/payments/:id/refundUse status codes precisely: 200 OK (success with body), 201 Created (POST success), 204 No Content (DELETE/PATCH with no body), 400 Bad Request (validation failure), 401 Unauthorized (not authenticated), 403 Forbidden (authenticated but not authorized), 404 Not Found, 409 Conflict (duplicate), 422 Unprocessable Entity (semantic validation), 429 Too Many Requests, 500 Internal Server Error.
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.statusCode = statusCode;
this.code = code;
}
}
app.delete('/api/orders/:id', asyncWrap(async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) throw new AppError('Order not found', 404, 'RESOURCE_NOT_FOUND');
if (order.status === 'shipped')
throw new AppError('Cannot delete shipped order', 409, 'INVALID_STATE');
await order.delete();
res.status(204).end();
}));Offset pagination (LIMIT 20 OFFSET 10000) requires the DB to scan and discard 10,000 rows — slow on large tables and unstable when records are inserted between pages. Cursor pagination (WHERE id > lastId LIMIT 20) uses an indexed column, is O(1) regardless of depth, and is stable because the cursor is an absolute position.
app.get('/api/orders', asyncWrap(async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const cursor = req.query.cursor
? JSON.parse(Buffer.from(req.query.cursor, 'base64').toString())
: null;
const where = cursor ? { id: { $gt: cursor.id } } : {};
const rows = await Order.findAll({ where, limit: limit + 1, order: [['id','ASC']] });
const hasNext = rows.length > limit;
const data = rows.slice(0, limit);
const nextCursor = hasNext
? Buffer.from(JSON.stringify({ id: data.at(-1).id })).toString('base64')
: null;
res.json({ data, pagination: { nextCursor, hasNext } });
}));Let clients specify which fields they need via a fields query parameter. The server fetches only those columns, reducing DB load, network payload, and serialization cost. Essential for mobile clients on slow connections and for avoiding waste when clients discard most of the returned data.
// GET /api/orders?fields=id,status,total,user.email
app.get('/api/orders', asyncWrap(async (req, res) => {
const ALLOWED = new Set(['id','status','total','createdAt','user.email','user.name']);
const fields = (req.query.fields ?? '')
.split(',')
.filter(f => ALLOWED.has(f));
const attributes = fields.length ? fields.filter(f => !f.includes('.')) : undefined;
const orders = await Order.findAll({
attributes,
include: fields.some(f => f.startsWith('user')) ? [User] : []
});
res.json({ data: orders });
}));A consistent error shape means client code only needs one error-handling path. The envelope includes: a machine-readable code, human-readable message, optional details array for field-level validation errors, and a requestId for support tracing.
// Error envelope
// { "error": { "code": "VALIDATION_FAILED", "message": "...",
// "requestId": "d4e5...", "details": [{ "field": "email", "message": "Invalid" }] } }
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
res.status(status).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: status < 500 ? err.message : 'An unexpected error occurred',
requestId: req.id,
details: err.details || []
}
});
});Attach a unique ID to every request on entry — use the client-supplied X-Request-ID header if present, otherwise generate one. Propagate it in outgoing HTTP calls to downstream services. Log every request with this ID. When an error occurs, searching logs by request ID reconstructs the full path across services.
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] || crypto.randomUUID();
res.setHeader('X-Request-ID', req.id);
next();
});
async function callUserService(userId, req) {
const resp = await fetch(`${USER_SERVICE}/users/${userId}`, {
headers: {
'Authorization': req.headers.authorization,
'X-Request-ID': req.id,
'Content-Type': 'application/json'
}
});
if (!resp.ok) throw new Error(`UserService ${resp.status}`);
return resp.json();
}An ETag is a fingerprint (hash) of the response body. The server returns it in the ETag header. On subsequent requests the client sends If-None-Match: <etag>. If the resource hasn't changed, the server responds with 304 Not Modified and no body — saving bandwidth and DB load.
app.get('/api/products/:id', asyncWrap(async (req, res) => {
const product = await Product.findById(req.params.id);
if (!product) return res.status(404).end();
const etag = `"${crypto.createHash('md5').update(JSON.stringify(product)).digest('hex')}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'private, max-age=60');
res.json(product);
}));Network failures may cause a client to retry a POST that the server already processed. An idempotency key (UUID sent in the header) lets the server detect duplicates: store the key and result on first processing; on retry, return the cached result without re-executing the operation. Critical for payments and order creation.
app.post('/api/payments', asyncWrap(async (req, res) => {
const key = req.headers['idempotency-key'];
if (!key) return res.status(400).json({ error: 'Idempotency-Key header required' });
const cached = await redis.get(`idem:${key}`);
if (cached) {
return res.status(200)
.setHeader('Idempotent-Replayed', 'true')
.json(JSON.parse(cached));
}
const payment = await paymentService.charge(req.body);
await redis.setex(`idem:${key}`, 86400, JSON.stringify(payment));
res.status(201).json(payment);
}));Opening a TCP connection and authenticating to Postgres takes ~5–20ms. A pool pre-creates N connections and reuses them. Requests acquire a connection, execute queries, and release it back to the pool. Without a pool, a 500 req/s API would attempt 500 new connections/s — quickly exhausting the database's connection limit.
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 2_000
});
async function transfer(fromId, toId, amount) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, fromId]);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, toId]);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}Prisma reads a schema.prisma file and generates a TypeScript client with full type inference. Migrations are generated from schema diffs. The client handles connection pooling and parameterized queries automatically, eliminating entire classes of SQL injection and type bugs.
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const orders = await prisma.order.findMany({
where: { userId: req.user.id, status: { in: ['pending', 'processing'] } },
include: { items: { include: { product: true } }, user: { select: { email: true } } },
orderBy: { createdAt: 'desc' },
take: 20
});
await prisma.user.upsert({
where: { email: data.email },
update: { name: data.name },
create: { email: data.email, name: data.name }
});
await prisma.$transaction([
prisma.order.create({ data: orderData }),
prisma.inventory.update({ where: { id: productId }, data: { stock: { decrement: 1 } } })
]);Mongoose schema defines types, required fields, defaults, validators, and virtuals. Validation runs before every save(). Pre/post hooks let you hash passwords or update timestamps automatically.
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true, lowercase: true,
match: [/^\S+@\S+\.\S+$/, 'Invalid email'] },
password: { type: String, required: true, minlength: 8, select: false },
role: { type: String, enum: ['user','admin'], default: 'user' }
});
userSchema.pre('save', async function (next) {
if (this.isModified('password'))
this.password = await bcrypt.hash(this.password, 12);
next();
});
const User = mongoose.model('User', userSchema);
try {
await User.create({ email: 'bad', password: '123' });
} catch (e) {
console.log(e.errors.email.message); // 'Invalid email'
console.log(e.errors.password.message); // 'shorter than minimum'
}Cache the result of expensive queries in Redis with a TTL. On subsequent requests, serve from cache — no DB round-trip. Invalidate the key when the underlying data changes. A cache hit for a 50ms DB query costs ~0.1ms.
const redis = require('ioredis');
const client = new redis(process.env.REDIS_URL);
async function getProductWithCache(id) {
const cacheKey = `product:${id}`;
const cached = await client.get(cacheKey);
if (cached) return JSON.parse(cached);
const product = await Product.findById(id).populate('category');
if (product) await client.setex(cacheKey, 300, JSON.stringify(product));
return product;
}
async function updateProduct(id, data) {
const product = await Product.findByIdAndUpdate(id, data, { new: true });
await client.del(`product:${id}`); // bust cache
return product;
}A repository class wraps all DB access for a resource. Service classes call repository methods and never touch the ORM/driver directly. This lets you swap Postgres for MongoDB without touching service code, and makes unit testing trivial — inject a mock repository.
class OrderRepository {
findById(id) { return prisma.order.findUnique({ where: { id } }); }
findByUser(userId) { return prisma.order.findMany({ where: { userId } }); }
create(data) { return prisma.order.create({ data }); }
updateStatus(id, s) { return prisma.order.update({ where: { id }, data: { status: s } }); }
}
class OrderService {
constructor(repo) { this.repo = repo; }
getOrdersForUser(userId) { return this.repo.findByUser(userId); }
}
// Unit test — no real DB needed
const mockRepo = { findByUser: jest.fn().mockResolvedValue([]) };
const service = new OrderService(mockRepo);Migration files are timestamped scripts with up() and down() functions. The migration runner records which have been applied in a migrations table. Running migrations in CI before tests, and in deployment before app startup, ensures the DB schema always matches the code expecting it.
// migrations/20240312_add_orders_index.js
exports.up = knex => knex.schema.table('orders', t => {
t.index(['user_id', 'status'], 'idx_orders_user_status');
t.index('created_at', 'idx_orders_created_at');
});
exports.down = knex => knex.schema.table('orders', t => {
t.dropIndex('idx_orders_user_status');
t.dropIndex('idx_orders_created_at');
});
// package.json
// "migrate": "knex migrate:latest"
// "migrate:down": "knex migrate:rollback"Without batching, loading 50 orders and their user details triggers 1 + 50 queries. DataLoader batches all user-ID lookups that happen within the same event loop tick into one WHERE id IN (...) query — 2 queries total regardless of list size.
const DataLoader = require('dataloader');
function createLoaders() {
return {
user: new DataLoader(async (userIds) => {
const users = await User.findAll({ where: { id: userIds } });
const map = Object.fromEntries(users.map(u => [u.id, u]));
return userIds.map(id => map[id] || null); // maintain order
})
};
}
async function resolveOrderUser(order, _, { loaders }) {
return loaders.user.load(order.userId); // batched automatically
}Two users read the same record, both modify it, both write back — the second write silently overwrites the first. Optimistic locking adds a version column: the UPDATE only succeeds if version matches what was read. On mismatch, the operation is retried or the conflict is surfaced to the user.
async function updateOrder(id, data, expectedVersion) {
const result = await pool.query(
`UPDATE orders
SET status = $1, updated_at = NOW(), version = version + 1
WHERE id = $2 AND version = $3
RETURNING *`,
[data.status, id, expectedVersion]
);
if (result.rowCount === 0)
throw new AppError('Conflict — resource modified by another request', 409, 'VERSION_CONFLICT');
return result.rows[0];
}bcrypt is a slow, adaptive hash function with a built-in salt. The work factor (cost) determines iterations — doubling cost halves brute-force speed. Never compare hashes with ===; use bcrypt.compare which is timing-safe. Never use fast hashes (MD5, SHA-1) for passwords.
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // ~300ms on modern hardware
async function hashPassword(plaintext) {
return bcrypt.hash(plaintext, SALT_ROUNDS);
}
async function verifyPassword(plaintext, hash) {
return bcrypt.compare(plaintext, hash); // timing-safe boolean
}
app.post('/api/auth/login', asyncWrap(async (req, res) => {
const user = await User.findOne({ email: req.body.email }).select('+password');
const ok = user && await verifyPassword(req.body.password, user.password);
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
res.json({ token: signJWT(user) });
}));A JWT is a signed, base64-encoded payload. The server verifies the signature — no DB lookup needed. Use short expiry (15min–1h) + refresh tokens. Never store sensitive data in the payload — it's base64-encoded, not encrypted.
const jwt = require('jsonwebtoken');
function signJWT(user) {
return jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m', issuer: 'myapp' }
);
}
function authenticate(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Token required' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'myapp' });
next();
} catch (err) {
res.status(401).json({ error: err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token' });
}
}dotenv loads a .env file into process.env at startup. The .env file is never committed. Use .env.example with placeholder values to document required variables. In production, inject secrets via the deployment platform (K8s secrets, AWS Parameter Store).
require('dotenv').config({ path: `.env.${process.env.NODE_ENV || 'development'}` });
// Validate required vars at startup — fail fast
const required = ['DATABASE_URL','JWT_SECRET','REDIS_URL','PORT'];
const missing = required.filter(k => !process.env[k]);
if (missing.length) {
console.error('Missing env vars:', missing.join(', '));
process.exit(1);
}
// .env.example (committed)
// DATABASE_URL=postgres://user:pass@localhost:5432/mydb
// JWT_SECRET=replace-with-random-256-bit-secret
// REDIS_URL=redis://localhost:6379
// PORT=3000Short-lived access tokens (15min) limit exposure if stolen. Long-lived refresh tokens (7 days) are stored in an HTTP-only cookie. On expiry, the client sends the refresh token to get new tokens — the old refresh token is invalidated. Detect theft via reuse detection: if a revoked token is used, revoke the entire family.
app.post('/api/auth/refresh', asyncWrap(async (req, res) => {
const token = req.cookies.refreshToken;
const stored = token && await RefreshToken.findOne({ token });
if (!stored || stored.revoked || stored.expiresAt < new Date())
return res.status(401).json({ error: 'Invalid or expired refresh token' });
await stored.update({ revoked: true });
const newRefresh = crypto.randomBytes(40).toString('hex');
await RefreshToken.create({ token: newRefresh, userId: stored.userId,
expiresAt: new Date(Date.now() + 7 * 86400000) });
const user = await User.findByPk(stored.userId);
res.cookie('refreshToken', newRefresh, { httpOnly: true, secure: true, sameSite: 'strict' });
res.json({ accessToken: signJWT(user) });
}));RBAC middleware runs after authentication and checks whether the authenticated user's role has permission. Centralize the check in a reusable factory function rather than repeating if (req.user.role !== 'admin') in every handler.
const authorize = (...roles) => (req, res, next) => {
if (!roles.includes(req.user?.role))
return res.status(403).json({ error: 'Insufficient permissions' });
next();
};
app.get('/api/admin/users', authenticate, authorize('admin'), listUsers);
app.get('/api/orders', authenticate, authorize('user','admin'), listOrders);
// Resource-level authorization — owner check
app.delete('/api/orders/:id', authenticate, asyncWrap(async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) return res.status(404).end();
if (order.userId !== req.user.sub && req.user.role !== 'admin')
return res.status(403).json({ error: 'Not your order' });
await order.delete();
res.status(204).end();
}));String concatenation lets user input escape the query context and inject SQL logic. Parameterized queries pass user input as bound values separate from the query string — the driver ensures they can never be interpreted as SQL. This is the single most important DB security practice in Node.js.
// ❌ SQL injection — never do this
const sql = `SELECT * FROM users WHERE id = ${req.query.id}`;
// ✅ Parameterized — driver escapes automatically
const { rows } = await pool.query(
'SELECT id, email, name FROM users WHERE id = $1',
[req.query.id]
);
// ✅ Knex query builder
const user = await knex('users')
.select('id', 'email')
.where('id', req.query.id)
.first();
// ✅ Prisma — all values parameterized by design
const user = await prisma.user.findUnique({ where: { id: Number(req.query.id) } });A Transform stream is both readable and writable. It receives chunks, processes them, and pushes transformed chunks downstream. Chain multiple transforms to build composable pipelines — parse CSV, filter rows, compress — without ever holding the full dataset in memory.
const { Transform } = require('stream');
const fs = require('fs');
const zlib = require('zlib');
const upperCase = new Transform({
transform(chunk, encoding, callback) {
this.push(chunk.toString().toUpperCase());
callback();
}
});
const csvFilter = new Transform({
objectMode: true,
transform(line, _, cb) {
const [id, name, price] = line.split(',');
if (parseFloat(price) > 10) this.push({ id, name, price: parseFloat(price) });
cb();
}
});
fs.createReadStream('input.txt')
.pipe(upperCase)
.pipe(zlib.createGzip())
.pipe(fs.createWriteStream('output.gz'));Node.js runs on one CPU core by default. The cluster module forks the process N times (one per core). Each worker has its own event loop; the master distributes incoming connections across workers via round-robin. A crash in one worker doesn't affect others — the master restarts it.
const cluster = require('cluster');
const os = require('os');
if (cluster.isPrimary) {
const cpus = os.cpus().length;
console.log(`Master ${process.pid} — forking ${cpus} workers`);
for (let i = 0; i < cpus; i++) cluster.fork();
cluster.on('exit', (worker, code) => {
console.warn(`Worker ${worker.pid} died (${code}) — restarting`);
cluster.fork();
});
} else {
require('./app');
console.log(`Worker ${process.pid} started`);
}PM2 is a process manager that restarts crashed apps, runs multiple instances in cluster mode, rotates logs, and starts on system boot. The ecosystem.config.js file version-controls your PM2 configuration.
// ecosystem.config.js
module.exports = {
apps: [{
name: 'my-api',
script: './src/index.js',
instances: 'max',
exec_mode: 'cluster',
watch: false,
max_memory_restart: '500M',
env_production: { NODE_ENV: 'production', PORT: 3000 },
error_file: './logs/error.log',
out_file: './logs/out.log'
}]
};
// pm2 start ecosystem.config.js --env production
// pm2 monit → live dashboard
// pm2 reload all → zero-downtime reloadStart Node.js with --inspect to expose a DevTools debugging endpoint. In Chrome DevTools Performance tab, record a CPU profile while the server is under load. Flame charts reveal which functions consume the most time. The built-in profiler uses V8 sampling — minimal overhead.
# Start with inspector
node --inspect src/index.js
# → Debugger listening on ws://127.0.0.1:9229
# Open chrome://inspect → Remote Target → Inspect
# Performance tab → Record → send traffic → Stop → flame chart
# Sampling profiler (no Chrome needed)
node --prof src/index.js # creates isolate-*.log
# send traffic, then:
node --prof-process isolate-*.log > profile.txtHTTP cache headers (Cache-Control, ETag) let proxies, CDNs, and browsers cache responses — the request never reaches the server. In-process memoization caches computed values in memory — fast but not shared across cluster workers or pods. Redis is the middle ground: shared across instances, invalidatable.
// HTTP cache — browser/CDN caches the response
app.get('/api/products', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
res.json(products);
});
// In-process memoization (not cluster-safe — use Redis for shared cache)
const memo = new Map();
function memoize(fn, ttl) {
return async (...args) => {
const key = JSON.stringify(args);
const hit = memo.get(key);
if (hit && hit.exp > Date.now()) return hit.val;
const val = await fn(...args);
memo.set(key, { val, exp: Date.now() + ttl });
return val;
};
}V8's default heap limit is ~1.5 GB on 64-bit systems regardless of available RAM. Node.js crashes with JavaScript heap out of memory when the heap exceeds this. Raise the limit with --max-old-space-size=4096 (MB). Set it to ~75% of available container memory — leave headroom for the OS and libuv thread pool.
node --max-old-space-size=4096 src/index.js
# In package.json: "start": "node --max-old-space-size=4096 src/index.js"
# In Docker: ENV NODE_OPTIONS="--max-old-space-size=4096"Jest groups related tests in describe blocks and individual cases in it. beforeEach runs setup before every test in the block. beforeAll runs once — use for expensive setup like starting a DB connection.
const { hashPassword, verifyPassword } = require('./auth');
describe('Auth utilities', () => {
describe('hashPassword', () => {
it('returns a bcrypt hash string', async () => {
const hash = await hashPassword('secret123');
expect(hash).toMatch(/^\$2b\$/);
});
it('produces different hashes for the same input', async () => {
const a = await hashPassword('same');
const b = await hashPassword('same');
expect(a).not.toBe(b); // unique salt per hash
});
});
describe('verifyPassword', () => {
let hash;
beforeEach(async () => { hash = await hashPassword('testpass'); });
it('returns true for matching password', async () => expect(await verifyPassword('testpass', hash)).toBe(true));
it('returns false for non-matching password', async () => expect(await verifyPassword('wrong', hash)).toBe(false));
});
});Supertest takes an Express app (not a running server) and spins up an ephemeral server per test — no port conflicts, no cleanup required. Chain assertions on status, headers, and body. Combine with a real test database for true integration confidence.
const request = require('supertest');
const app = require('../src/app'); // export app without .listen()
describe('POST /api/auth/register', () => {
it('creates user and returns 201', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({ email: 'test@example.com', password: 'password123' })
.expect(201)
.expect('Content-Type', /json/);
expect(res.body).toMatchObject({ email: 'test@example.com' });
expect(res.body).not.toHaveProperty('password');
});
it('returns 422 for invalid email', async () => {
await request(app)
.post('/api/auth/register')
.send({ email: 'bad-email', password: 'password123' })
.expect(422);
});
});jest.mock('module') replaces the real module with an auto-mock whose every function is a jest.fn(). Use mockResolvedValue to control what the mock returns, and toHaveBeenCalledWith to assert invocation. Reset mocks between tests to avoid pollution.
jest.mock('../services/emailService');
const emailService = require('../services/emailService');
const { registerUser } = require('../services/userService');
describe('registerUser', () => {
beforeEach(() => jest.resetAllMocks());
it('sends a welcome email after creating the user', async () => {
emailService.sendWelcome.mockResolvedValue({ messageId: 'msg_123' });
await registerUser({ email: 'alice@example.com', password: 'secret123' });
expect(emailService.sendWelcome).toHaveBeenCalledTimes(1);
expect(emailService.sendWelcome).toHaveBeenCalledWith(
expect.objectContaining({ email: 'alice@example.com' })
);
});
it('throws if email service fails', async () => {
emailService.sendWelcome.mockRejectedValue(new Error('SMTP down'));
await expect(registerUser({ email: 'b@b.com', password: 'pass1234' }))
.rejects.toThrow('SMTP down');
});
});Coverage instruments the code and reports which lines, branches, and functions were executed by tests. Enforce minimum thresholds in jest.config.js — CI fails if coverage drops below the threshold. Focus coverage on business logic, not boilerplate.
// jest.config.js
module.exports = {
collectCoverageFrom: ['src/**/*.js', '!src/**/*.test.js', '!src/index.js'],
coverageThresholds: {
global: { lines: 80, functions: 80, branches: 70, statements: 80 }
},
coverageReporters: ['text', 'lcov', 'html']
};
// Run: jest --coverage
// -----------------------|---------|----------|---------|---------|
// File | % Stmts | % Branch | % Funcs | % Lines |
// src/services/auth.js | 92.3 | 85.7 | 100 | 92.3 |Plain console.log strings are hard to search and aggregate. Pino emits newline-delimited JSON — every entry is a parseable object with timestamp, level, message, and custom fields. Log aggregators (Datadog, Loki, CloudWatch) can filter, alert, and dashboard on structured fields.
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined
});
logger.info({ userId: user.id, orderId: order.id }, 'Order placed');
logger.error({ err, requestId: req.id }, 'Payment failed');
app.use((req, res, next) => {
req.log = logger.child({ requestId: req.id, method: req.method, path: req.path });
next();
});Set breakpoints, inspect variables, and step through async code in VS Code with a launch.json configuration. The debugger pauses on exceptions — far faster than adding and removing console.log statements.
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug API",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/src/index.js",
"env": { "NODE_ENV": "development" },
"restart": true,
"console": "integratedTerminal"
},
{
"name": "Attach to Running Process",
"type": "node",
"request": "attach",
"port": 9229,
"restart": true
}
]
}Stage 1 (build) installs dev dependencies and compiles TypeScript. Stage 2 (runtime) starts from a slim base image and copies only compiled output and production node_modules. The final image has no source, test files, or dev tools — a typical reduction from 1.2 GB to 120 MB.
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json .
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]Without .dockerignore, the entire project directory including node_modules, .git, test fixtures, and logs is sent to the Docker daemon — turning a 5 MB build context into 500 MB. Ignoring unnecessary files keeps the context small and prevents secrets from leaking into the image.
# .dockerignore
node_modules
dist
.git
.env
.env.*
*.log
coverage
__tests__
*.test.js
*.spec.js
README.md
.vscodeKubernetes sends SIGTERM before killing a pod. The app should stop accepting new connections immediately, wait for active requests to complete, close DB pools, then exit. Without this, connections are abruptly terminated — clients get 502 errors during deployments.
const server = app.listen(process.env.PORT || 3000);
async function shutdown() {
server.close(async () => {
await prisma.$disconnect();
await redisClient.quit();
process.exit(0);
});
setTimeout(() => process.exit(1), 30_000); // force-kill after 30s
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);/healthz (liveness) returns 200 if the process is alive. /readyz (readiness) also checks DB and Redis — a pod that can't reach the DB should be removed from load balancer rotation until it recovers.
app.get('/healthz', (req, res) => res.status(200).json({ status: 'ok' }));
app.get('/readyz', asyncWrap(async (req, res) => {
const checks = await Promise.allSettled([
pool.query('SELECT 1'),
redis.ping()
]);
const results = {
database: checks[0].status === 'fulfilled' ? 'ok' : 'error',
redis: checks[1].status === 'fulfilled' ? 'ok' : 'error'
};
const healthy = Object.values(results).every(v => v === 'ok');
res.status(healthy ? 200 : 503).json({ status: healthy ? 'ready' : 'not ready', checks: results });
}));nginx handles TLS termination, static file serving, compression, and load balancing across Node.js instances. Node.js processes never directly expose port 443. This offloads TLS overhead and allows hot-reloading nginx config without touching the app.
upstream node_api {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
keepalive 32;
}
server {
listen 443 ssl;
server_name api.myapp.com;
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
gzip on;
gzip_types application/json text/plain;
location /api {
proxy_pass http://node_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
}
}A GitHub Actions workflow runs on every push: install, lint, test with coverage, build Docker image, push to registry, deploy. Failures on any step block the deployment, ensuring no untested or unlinted code reaches production.
name: CI/CD
on: [push]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env: { POSTGRES_PASSWORD: test, POSTGRES_DB: testdb }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
env: { DATABASE_URL: postgres://postgres:test@localhost/testdb }
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: docker build -t myapp:${{ github.sha }} .
- run: kubectl set image deployment/api api=myapp:${{ github.sha }}A config module reads NODE_ENV and merges base settings with environment overrides. Test environments use a local DB; production reads secrets from environment variables injected by the platform. Never hard-code environment-specific values in code.
const base = {
port: parseInt(process.env.PORT || '3000'),
logLevel: process.env.LOG_LEVEL || 'info'
};
const env = {
development: { db: { url: 'postgres://localhost/myapp_dev' }, logLevel: 'debug' },
test: { db: { url: process.env.TEST_DATABASE_URL }, logLevel: 'silent' },
production: { db: { url: process.env.DATABASE_URL, pool: { min: 5, max: 20 } }, logLevel: 'warn' }
};
module.exports = { ...base, ...(env[process.env.NODE_ENV] || env.development) };Kubernetes replaces pods one at a time. New pods must pass readiness probes before old ones are terminated. The app must handle SIGTERM gracefully — drain connections, exit within terminationGracePeriodSeconds. Combined, this achieves zero dropped requests during deployment.
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
template:
spec:
terminationGracePeriodSeconds: 35
containers:
- name: api
image: myapp:v1.2.3
readinessProbe:
httpGet: { path: /readyz, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: 3000 }
periodSeconds: 10postMessage serializes (copies) data between threads — expensive for large buffers. SharedArrayBuffer is a raw memory region both threads can read and write simultaneously. Use Atomics for synchronization to prevent race conditions. Ideal for image processing or numerical workloads.
// main.js
const { Worker } = require('worker_threads');
const sab = new SharedArrayBuffer(1024);
const arr = new Int32Array(sab);
const worker = new Worker('./worker.js', { workerData: { sab } });
worker.on('message', () => console.log('Result:', arr[0])); // no copy
// worker.js
const { workerData, parentPort } = require('worker_threads');
const arr = new Int32Array(workerData.sab);
Atomics.add(arr, 0, 42); // thread-safe increment
parentPort.postMessage('done');HTTP is request-response; neither side can push unsolicited messages. WebSocket upgrades the connection to a persistent full-duplex channel — the server can push data to clients instantly. Horizontal scaling requires a shared pub/sub backend (Redis) so events reach the correct server pod.
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ server: httpServer });
const connections = new Map(); // userId → Set of WebSocket connections
wss.on('connection', (ws, req) => {
const userId = getUserIdFromRequest(req);
if (!connections.has(userId)) connections.set(userId, new Set());
connections.get(userId).add(ws);
ws.on('close', () => connections.get(userId)?.delete(ws));
});
function pushToUser(userId, event) {
connections.get(userId)?.forEach(ws => {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(event));
});
}SSE uses a long-lived HTTP connection with text/event-stream content type. The browser's built-in EventSource API reconnects automatically on disconnect. Simpler than WebSockets for one-way push (notifications, live dashboards, job progress) and works through HTTP/2 with multiplexing.
app.get('/api/events', authenticate, (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const send = (event, data) =>
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
const unsubscribe = pubsub.subscribe(`user:${req.user.sub}`, msg => send(msg.type, msg.data));
req.on('close', unsubscribe);
});
// Browser
const es = new EventSource('/api/events');
es.addEventListener('order:updated', e => updateOrderUI(JSON.parse(e.data)));GraphQL exposes a single endpoint. Clients send queries specifying exactly the fields they need. The server maps each field to a resolver function. Apollo Server handles schema parsing, query validation, execution, and context injection (auth, loaders).
const { ApolloServer, gql } = require('@apollo/server');
const typeDefs = gql`
type User { id: ID! email: String! orders: [Order!]! }
type Order { id: ID! total: Float! status: String! }
type Query { user(id: ID!): User orders: [Order!]! }
type Mutation { createOrder(input: CreateOrderInput!): Order! }
input CreateOrderInput { userId: ID! total: Float! }
`;
const resolvers = {
Query: { user: (_, { id }, { db }) => db.users.findById(id),
orders: (_, __, { db, user }) => db.orders.findByUser(user.id) },
User: { orders: (p, _, { loaders }) => loaders.ordersByUser.load(p.id) },
Mutation: { createOrder: (_, { input }, { db }) => db.orders.create(input) }
};Lambda invokes a handler function per request — no persistent server. Cold starts spin up a new runtime; warm invocations reuse the same process. Move DB connection setup outside the handler to module scope so it's reused across warm invocations, not re-created per request.
let dbPool;
async function getPool() {
if (!dbPool) dbPool = new Pool({ connectionString: process.env.DATABASE_URL });
return dbPool;
}
exports.handler = async (event, context) => {
context.callbackWaitsForEmptyEventLoop = false;
const pool = await getPool();
const { id } = event.pathParameters;
const { rows } = await pool.query('SELECT * FROM orders WHERE id = $1', [id]);
return {
statusCode: rows.length ? 200 : 404,
headers: { 'Content-Type': 'application/json' },
body: rows.length ? JSON.stringify(rows[0]) : JSON.stringify({ error: 'Not found' })
};
};After N consecutive failures to a downstream service, the circuit "opens" — subsequent calls immediately throw without attempting the real call, giving the downstream service time to recover. After a timeout it enters "half-open", allows one probe request, and closes if it succeeds.
const CircuitBreaker = require('opossum');
const breaker = new CircuitBreaker(callPaymentService, {
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 10000
});
breaker.fallback(() => ({ status: 'pending', message: 'Payment queued' }));
breaker.on('open', () => logger.warn('Circuit OPEN — payment service unavailable'));
breaker.on('close', () => logger.info('Circuit CLOSED — payment service recovered'));
const result = await breaker.fire(paymentPayload);OpenTelemetry auto-instruments HTTP, DB, and Redis calls, creating spans linked by a trace ID that flows through service calls via propagation headers. Spans are exported to a backend (Jaeger, Tempo, Datadog). A single trace shows the full path of a request with timing for each hop.
// tracing.js — require BEFORE everything else
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_ENDPOINT }),
instrumentations: [getNodeAutoInstrumentations()]
});
sdk.start();
// Manual span for custom operations
const { trace } = require('@opentelemetry/api');
const tracer = trace.getTracer('my-service');
async function processOrder(order) {
return tracer.startActiveSpan('processOrder', async span => {
span.setAttribute('order.id', order.id);
try { return await chargePayment(order); }
catch (err) { span.recordException(err); throw err; }
finally { span.end(); }
});
}Memory leaks occur when objects are retained indefinitely — forgotten event listeners, closures holding references, ever-growing caches. Symptoms: heap climbs monotonically, GC pauses increase, eventual OOM. Diagnose with V8 heap snapshots taken at intervals; compare to find growing object types.
// ❌ Event listener never removed
emitter.on('data', bigHandler); // holds reference forever
// ✅ Remove when done
emitter.off('data', bigHandler);
// ❌ Unbounded cache
const cache = {};
function getUser(id) { return (cache[id] = cache[id] || fetchUser(id)); }
// ✅ LRU cache with max size and TTL
const LRU = require('lru-cache');
const cache2 = new LRU({ max: 1000, ttl: 300_000 });
// Heap snapshot
const v8 = require('v8');
const path = v8.writeHeapSnapshot();
console.log('Snapshot:', path); // open in Chrome DevTools → Memory tabInstead of a route handler synchronously sending email and resizing images, it publishes a job to a queue and responds immediately. Workers pull jobs and execute them independently. If a worker crashes, the job is re-queued. Producers and consumers scale independently.
const { Queue, Worker } = require('bullmq');
const conn = { host: 'localhost', port: 6379 };
const emailQueue = new Queue('email', { connection: conn });
// Producer — fast response
app.post('/api/orders', asyncWrap(async (req, res) => {
const order = await Order.create(req.body);
await emailQueue.add('order-confirmation',
{ orderId: order.id, email: req.user.email },
{ attempts: 3, backoff: { type: 'exponential', delay: 1000 } }
);
res.status(201).json(order); // responds without waiting for email
}));
// Consumer — separate process
new Worker('email', async (job) => {
const order = await Order.findById(job.data.orderId);
await emailService.sendConfirmation(job.data.email, order);
}, { connection: conn, concurrency: 5 });HTTP/2 multiplexes multiple requests over one TCP connection (eliminating head-of-line blocking), compresses headers with HPACK (reducing overhead from auth tokens sent on every request), and supports server push. APIs with many small requests benefit significantly. In practice, let nginx handle HTTP/2 and proxy HTTP/1.1 to Node.
const http2 = require('http2');
const fs = require('fs');
const server = http2.createSecureServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt')
});
server.on('stream', (stream, headers) => {
if (headers[':method'] === 'GET' && headers[':path'] === '/api/data') {
stream.respond({ ':status': 200, 'content-type': 'application/json' });
stream.end(JSON.stringify({ data: 'http2 response' }));
}
});
server.listen(443);Fastify uses JSON schema validation via ajv to serialize responses 2× faster than JSON.stringify, a trie-based router faster than Express's linear regex router, and schema-based input validation compiled to native code. Typically 2–3× higher throughput than Express.
const fastify = require('fastify')({ logger: true });
const opts = {
schema: {
querystring: { type: 'object', properties: { limit: { type: 'integer' } } },
response: {
200: {
type: 'object',
properties: {
data: { type: 'array', items: {
type: 'object',
properties: { id: { type: 'integer' }, name: { type: 'string' } }
}}
}
}
}
}
};
fastify.get('/api/users', opts, async (request) => {
return { data: await User.findAll({ limit: request.query.limit || 20 }) };
});
await fastify.listen({ port: 3000 });With streaming uploads, files are piped directly to disk or cloud storage without fully buffering in memory. A 500 MB upload uses constant ~1 MB of RAM; buffering fully to memory would use 500 MB and risk OOM on concurrent uploads.
const multer = require('multer');
const { Upload } = require('@aws-sdk/lib-storage');
const { S3Client } = require('@aws-sdk/client-s3');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
app.post('/api/upload', upload.single('file'), asyncWrap(async (req, res) => {
const s3 = new S3Client({ region: 'us-east-1' });
const up = new Upload({
client: s3,
params: {
Bucket: process.env.S3_BUCKET,
Key: `uploads/${Date.now()}-${req.file.originalname}`,
Body: req.file.buffer,
ContentType: req.file.mimetype
}
});
const result = await up.done();
res.json({ url: result.Location });
}));Rather than importing dependencies directly inside modules, accept them as constructor parameters. Tests inject mocks; production injects real implementations. A simple service container wires everything at startup — no need for Inversify or decorators unless the project warrants it.
// ❌ Hard-coded — untestable
class OrderService {
async getOrder(id) { return require('./db').orders.findById(id); }
}
// ✅ Dependency injection
class OrderService {
constructor({ orderRepo, paymentService }) {
this.orderRepo = orderRepo;
this.paymentService = paymentService;
}
getOrder(id) { return this.orderRepo.findById(id); }
async placeOrder(data) {
const order = await this.orderRepo.create(data);
const charge = await this.paymentService.charge(order);
return { order, charge };
}
}
// Wire in app.js
const service = new OrderService({ orderRepo: require('./repos/order'), paymentService: require('./services/payment') });
// Wire in tests
const service = new OrderService({ orderRepo: mockRepo, paymentService: mockPayment });stream.pipeline handles cleanup automatically when any stream errors or ends — it destroys all remaining streams. The Promise-based stream/promises.pipeline wraps this with async/await, removing the need for manual .on('error') handlers on each stream in the chain.
const { pipeline } = require('stream/promises');
const fs = require('fs');
const zlib = require('zlib');
async function compressFile(src, dest) {
await pipeline(
fs.createReadStream(src),
zlib.createGzip(),
fs.createWriteStream(dest)
);
console.log(`Compressed ${src} → ${dest}`);
}
// HTTP streaming response
app.get('/download/:file', asyncWrap(async (req, res) => {
const src = path.join(UPLOADS_DIR, req.params.file);
res.setHeader('Content-Encoding', 'gzip');
await pipeline(fs.createReadStream(src), zlib.createGzip(), res);
}));Node.js 18+ ships a built-in test runner with describe, it, before, after, and mock. Run tests with node --test. No external dependencies — useful for libraries where keeping a zero-dependency install matters.
const { describe, it, mock, before } = require('node:test');
const assert = require('node:assert/strict');
describe('OrderService', () => {
let service;
before(() => {
const mockRepo = { findById: mock.fn(id => ({ id, total: 99.99 })) };
service = new OrderService({ orderRepo: mockRepo });
});
it('returns order by ID', async () => {
const order = await service.getOrder(1);
assert.equal(order.id, 1);
assert.equal(order.total, 99.99);
});
it('throws for missing order', async () => {
await assert.rejects(service.getOrder(null), /not found/i);
});
});
// Run: node --test **/*.test.jsAbortController creates a signal passed to fetch, fs.readFile, and custom async code. Calling abort() causes all pending operations holding the signal to reject with AbortError. Use for request timeouts, user cancellation, and graceful shutdown of long-running operations.
// Per-request timeout for downstream calls
app.get('/api/data', asyncWrap(async (req, res) => {
const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(new Error('Downstream timeout')), 5000);
try {
const data = await fetch('https://api.external.com/data', { signal: ac.signal });
res.json(await data.json());
} catch (err) {
if (err.name === 'AbortError') res.status(504).json({ error: 'Gateway timeout' });
else throw err;
} finally {
clearTimeout(timeout);
}
}));
// Cancel on client disconnect
app.get('/api/query', (req, res) => {
const ac = new AbortController();
req.on('close', () => ac.abort());
db.longQuery({ signal: ac.signal }).then(r => res.json(r)).catch(() => {});
});Top-level await in ESM lets you write async initialization code at the module level — no IIFE wrapper. The module won't finish loading until the await resolves, making it safe to export values that depend on async setup (DB connection, config fetch, schema load).
// config.js — ESM
const resp = await fetch('https://config-service/api/config');
export const config = await resp.json(); // importers wait for this fetch
// db.js — ESM
import pg from 'pg';
export const pool = new pg.Pool({ connectionString: config.databaseUrl });
await pool.query('SELECT 1'); // verify connection on module load
// app.js — no IIFE needed
import { pool } from './db.js'; // waits for db.js to finish
import { config } from './config.js';
import express from 'express';
const app = express();
app.listen(config.port, () => console.log(`Listening on ${config.port}`));Request context (user ID, request ID, tenant) is set once in middleware and automatically flows through all async operations — DB queries, event handlers, queued jobs — without threading it through every function parameter. Every log line emitted during the request automatically includes the context.
const { AsyncLocalStorage } = require('async_hooks');
const pino = require('pino');
const baseLogger = pino();
const als = new AsyncLocalStorage();
// Middleware — set context once per request
app.use((req, res, next) => {
const ctx = { requestId: req.headers['x-request-id'] || crypto.randomUUID(),
userId: req.user?.id };
als.run(ctx, next);
});
// Logger helper — always includes request context
function getLogger() {
const ctx = als.getStore() ?? {};
return baseLogger.child(ctx);
}
// Deep in a service — no ctx parameter needed
async function chargeCard(paymentDetails) {
getLogger().info({ amount: paymentDetails.amount }, 'Charging card');
const result = await stripe.charges.create(paymentDetails);
getLogger().info({ chargeId: result.id }, 'Charge successful');
return result;
}