Node.js Runtime Principal Engineer
Audience: Engineers diagnosing, tuning, or designing against the Node.js runtime itself - event loop behavior, streams, worker threads, memory, GC, native addons, process lifecycle.
Goal: Principal-engineer-grade understanding of Node internals sufficient to diagnose any production issue, tune for predictable tail latency, and design services that exploit the runtime correctly.
Priority Model
Correctness - Reliability - Security - Performance (event-loop health) - Observability - Scalability - Operability. In that order.
Core Principles
Node is single-threaded by design - the event loop is your critical section. Every request, timer, and I/O callback shares one JS execution context. A 50ms sync function at 100 QPS produces 100% event-loop utilization. Measure event-loop lag continuously (perf_hooks.monitorEventLoopDelay). P99 lag budget: sub-10ms for latency-sensitive services, sub-100ms for batch.
Blocking work belongs off the main thread. CPU-bound hashing, cryptography, image processing, parsing megabyte payloads - move to worker_threads with a pool pattern (piscina). Never crypto.pbkdf2Sync, zlib.gzipSync, JSON.parse on multi-MB payloads, or heavy regexes on the main thread. libuv's thread pool (default size 4) handles file I/O and some crypto - tune UV_THREADPOOL_SIZE only with measurement.
Streams with backpressure, or memory fails. Reading a file or HTTP body into a string/buffer for any non-trivial size is an OOM waiting. Use Node streams, stream/promises.pipeline, or Web Streams. Respect the pause/resume signal. for await (const chunk of stream) is the idiomatic consumer. Transform streams for pipeline processing.
Memory leaks come from references, not from allocation. Common leak sources: module-level Maps growing unbounded, event listeners never removed, setInterval without cleanup, closures retaining large objects, cached Buffers. Every cache needs an eviction policy (LRU via lru-cache). Take heap snapshots with --inspect + Chrome DevTools or v8.writeHeapSnapshot(); compare two points in time.
Context propagation is AsyncLocalStorage. Trace IDs, tenant IDs, user context across async boundaries - AsyncLocalStorage from node:async_hooks. Don't thread context through every function parameter. Don't use cls-hooked in new code (legacy). Beware that some native addons don't preserve async context.
Signals and graceful shutdown are first-class. SIGTERM starts a drain: stop accepting new connections, finish in-flight, close DB pools, close Kafka consumers, flush logs/traces, then exit. Use a shutdown() coordinator with a deadline. Kubernetes gives you terminationGracePeriodSeconds - use it.
Decision Framework
cluster vs. worker threads vs. horizontal scaling.
- Horizontal scaling (multiple pods/processes) - default for most deployments. Let the orchestrator handle it.
cluster module - single-box multi-core utilization when you can't add pods. Node 20+'s default scheduling is OK. Sticky sessions are your problem.
worker_threads - CPU-bound work offloaded from main thread within the same process, shared memory via SharedArrayBuffer when needed.
PM2 / forever / systemd / Kubernetes. Kubernetes + container for production. Systemd for single-box services. PM2 only for non-containerized environments; its clustering is inferior to k8s orchestration.
Native fetch vs. undici vs. axios vs. got. Native fetch (Node 18+) for simple cases. undici directly when you need keep-alive pool tuning, HTTP/2, or dispatcher control. axios and got are legacy ergonomics layers - not needed in modern Node.
Bun vs. Node 20+ LTS. Bun for speed-sensitive new services where the dep tree is compatible. Node 20+ LTS for mature ecosystems and native-module-heavy code. Don't mix in production without isolation.
V8 heap tuning. Default heap is ~1.7 GB on 64-bit. Increase via --max-old-space-size=NNNN (MB) when profiling shows legitimate need, not when plugging leaks. Remember containers: set the flag based on the cgroup limit, not the host RAM.
Logging: pino vs. winston vs. bunyan. pino for performance (the only serious choice for hot paths), winston for flexibility/transports in non-hot paths. bunyan is legacy.
Anti-Patterns
JSON.parse on a multi-MB body in a request handler. Blocks the event loop for tens of milliseconds. Stream-parse or move to a worker.
- Sync crypto (
crypto.pbkdf2Sync, bcrypt sync) on the main thread. Use async variants or worker threads.
fs.readFileSync at request time. Cache results at startup or use async.
- Unbounded caches.
Map or plain object growing per unique key. Use lru-cache.
setInterval without unref() or cleanup. Keeps process alive and leaks memory.
- Event listeners not removed on disposal. Classic leak - 11+ listener warning, then OOM.
- Not handling
unhandledRejection and uncaughtException. Default in Node 20+ is terminate on unhandled rejection (good). Install a handler that logs then exits; never absorb.
process.exit(0) without flushing logs/traces. Stdout is not flushed; telemetry batched; data loss. Use a shutdown() coordinator.
- Bundling a service with webpack/rollup. Usually unnecessary. Node runs
.js/.ts (with tsx/ts-node) directly. Bundling can break source maps and native modules.
--experimental-* flags in production. Experimental means it may change or crash. Pin to stable.
- Sharing state across
cluster workers via filesystem or globals. Use Redis or a real shared store.
- Ignoring
process.memoryUsage().rss vs. heapUsed. RSS includes native allocations; heap is only V8. Both matter.
- Logging Buffers or full objects. Massive log lines, PII leaks, I/O cost. Redact and truncate.
- Using
child_process.exec with user input. Shell injection. Use execFile with argv.
Diagnostic Workflow
Event-loop lag spike
perf_hooks.monitorEventLoopDelay() in prod continuously.
- Correlate lag spikes with request rate, GC pauses, and specific endpoints.
- Capture CPU profile for the spike window (
--cpu-prof or 0x flamegraph).
- Look for long sync functions. Move them to worker threads.
Memory leak
- Stable load test; RSS and heap over time via
process.memoryUsage().
- Two heap snapshots (baseline + after 10 min load). Compare retainers.
- Common suspects: closures, module-level Maps, unremoved listeners, pending timers.
--heap-prof for sampling heap profile.
CPU pegged at 100%
--cpu-prof or 0x flamegraph during the load.
- Look for JSON parse/stringify, regex, crypto sync calls.
- Measure event-loop lag; a CPU-bound service without lag is probably I/O-bound downstream.
Throughput regression
- Diff of event-loop utilization, GC time, and libuv thread pool saturation.
- Measure before and after a suspected commit with the same load profile (k6, autocannon).
- Node's
--trace-gc for GC behavior.
Runtime Defaults (2026 baseline)
- Runtime: Node 22 LTS (or Node 20 LTS minimum); Bun where validated.
- Flags:
--enable-source-maps in production; --max-old-space-size set from cgroup limit; --heapsnapshot-near-heap-limit=3 for post-mortem leak analysis; avoid --experimental-*.
- Environment:
NODE_ENV=production, UV_THREADPOOL_SIZE default unless measured.
- Observability: OpenTelemetry SDK for Node with auto-instrumentation of http, fs, dns, net, pg, mysql, redis; pino for logs;
prom-client for metrics; perf_hooks.monitorEventLoopDelay for lag.
- HTTP:
undici for client control; Fastify for server.
- Streams: Web Streams for edge, Node streams for traditional;
stream/promises.pipeline for composition.
- Workers:
piscina for worker thread pools.
- Shutdown: SIGTERM handler with deadline-bounded drain of HTTP server, DB pools, queue consumers, trace/log exporters.
Deliverables Contract
- Process lifecycle: SIGTERM handler that drains HTTP, DB, and queue connections within
terminationGracePeriodSeconds.
- Event-loop lag monitoring in production with SLO alert.
- Heap and RSS monitoring; alert when heap > 80% of limit.
- CPU profiling and heap snapshot tooling documented in runbook.
- Worker thread pool for any CPU-bound work >5ms.
- Bounded concurrency on all fan-out; bounded queues on all back-pressure paths.
- Streams with pipeline for all large-payload I/O.
- AsyncLocalStorage for trace/tenant context propagation.
- Pinned runtime (
engines + Dockerfile).
- Multi-stage Docker, non-root user, explicit heap limit.
- Runbook covering event-loop lag, memory leak, CPU saturation, throughput regression diagnostic playbooks.
Quality gates: event-loop lag P99 within budget under peak load, no sync crypto/compression/parsing on main thread, no unbounded caches or listeners, all timers cleaned up on shutdown, signal handlers installed, graceful shutdown drains within deadline, logs and traces flushed before exit.
1---2name: backend-pe-nodejs3description: Principal-engineer-grade guidance for the Node.js runtime itself - event loop, streams, worker threads, cluster, memory, GC, native addons, and runtime-level failure modes. Covers diagnosing event-loop lag, heap leaks, CPU profiling, AsyncLocalStorage, AbortController, and production-grade process lifecycle on Node 20+ LTS or Bun. Use when debugging or tuning Node runtime behavior, investigating latency spikes, memory leaks, throughput regressions, or designing at the runtime level. Trigger keywords - Node.js runtime, event loop, event loop lag, worker threads, cluster, streams backpressure, AsyncLocalStorage, heap snapshot, clinic.js, flamegraph, node --prof, memory leak Node, V8 tuning, AbortController, graceful shutdown Node. Not for language-level concerns (use backend-pe-javascript or backend-pe-typescript).4---56# Node.js Runtime Principal Engineer78**Audience:** Engineers diagnosing, tuning, or designing against the Node.js runtime itself - event loop behavior, streams, worker threads, memory, GC, native addons, process lifecycle.910**Goal:** Principal-engineer-grade understanding of Node internals sufficient to diagnose any production issue, tune for predictable tail latency, and design services that exploit the runtime correctly.1112## Priority Model1314Correctness - Reliability - Security - Performance (event-loop health) - Observability - Scalability - Operability. In that order.1516## Core Principles17181. **Node is single-threaded by design - the event loop is your critical section.** Every request, timer, and I/O callback shares one JS execution context. A 50ms sync function at 100 QPS produces 100% event-loop utilization. Measure event-loop lag continuously (`perf_hooks.monitorEventLoopDelay`). P99 lag budget: sub-10ms for latency-sensitive services, sub-100ms for batch.19202. **Blocking work belongs off the main thread.** CPU-bound hashing, cryptography, image processing, parsing megabyte payloads - move to `worker_threads` with a pool pattern (piscina). Never `crypto.pbkdf2Sync`, `zlib.gzipSync`, `JSON.parse` on multi-MB payloads, or heavy regexes on the main thread. libuv's thread pool (default size 4) handles file I/O and some crypto - tune `UV_THREADPOOL_SIZE` only with measurement.21223. **Streams with backpressure, or memory fails.** Reading a file or HTTP body into a string/buffer for any non-trivial size is an OOM waiting. Use Node streams, `stream/promises.pipeline`, or Web Streams. Respect the pause/resume signal. `for await (const chunk of stream)` is the idiomatic consumer. `Transform` streams for pipeline processing.23244. **Memory leaks come from references, not from allocation.** Common leak sources: module-level Maps growing unbounded, event listeners never removed, `setInterval` without cleanup, closures retaining large objects, cached `Buffer`s. Every cache needs an eviction policy (LRU via `lru-cache`). Take heap snapshots with `--inspect` + Chrome DevTools or `v8.writeHeapSnapshot()`; compare two points in time.25265. **Context propagation is AsyncLocalStorage.** Trace IDs, tenant IDs, user context across async boundaries - `AsyncLocalStorage` from `node:async_hooks`. Don't thread context through every function parameter. Don't use `cls-hooked` in new code (legacy). Beware that some native addons don't preserve async context.27286. **Signals and graceful shutdown are first-class.** SIGTERM starts a drain: stop accepting new connections, finish in-flight, close DB pools, close Kafka consumers, flush logs/traces, then exit. Use a `shutdown()` coordinator with a deadline. Kubernetes gives you `terminationGracePeriodSeconds` - use it.2930## Decision Framework3132**`cluster` vs. worker threads vs. horizontal scaling.**33- Horizontal scaling (multiple pods/processes) - default for most deployments. Let the orchestrator handle it.34- `cluster` module - single-box multi-core utilization when you can't add pods. Node 20+'s default `scheduling` is OK. Sticky sessions are your problem.35- `worker_threads` - CPU-bound work offloaded from main thread within the same process, shared memory via `SharedArrayBuffer` when needed.3637**PM2 / forever / systemd / Kubernetes.** Kubernetes + container for production. Systemd for single-box services. PM2 only for non-containerized environments; its clustering is inferior to k8s orchestration.3839**Native fetch vs. undici vs. axios vs. got.** Native `fetch` (Node 18+) for simple cases. `undici` directly when you need keep-alive pool tuning, HTTP/2, or dispatcher control. `axios` and `got` are legacy ergonomics layers - not needed in modern Node.4041**Bun vs. Node 20+ LTS.** Bun for speed-sensitive new services where the dep tree is compatible. Node 20+ LTS for mature ecosystems and native-module-heavy code. Don't mix in production without isolation.4243**V8 heap tuning.** Default heap is ~1.7 GB on 64-bit. Increase via `--max-old-space-size=NNNN` (MB) when profiling shows legitimate need, not when plugging leaks. Remember containers: set the flag based on the cgroup limit, not the host RAM.4445**Logging: pino vs. winston vs. bunyan.** pino for performance (the only serious choice for hot paths), winston for flexibility/transports in non-hot paths. bunyan is legacy.4647## Anti-Patterns4849- **`JSON.parse` on a multi-MB body in a request handler.** Blocks the event loop for tens of milliseconds. Stream-parse or move to a worker.50- **Sync crypto (`crypto.pbkdf2Sync`, bcrypt sync) on the main thread.** Use async variants or worker threads.51- **`fs.readFileSync` at request time.** Cache results at startup or use async.52- **Unbounded caches.** `Map` or plain object growing per unique key. Use `lru-cache`.53- **`setInterval` without `unref()` or cleanup.** Keeps process alive and leaks memory.54- **Event listeners not removed on disposal.** Classic leak - 11+ listener warning, then OOM.55- **Not handling `unhandledRejection` and `uncaughtException`.** Default in Node 20+ is terminate on unhandled rejection (good). Install a handler that logs then exits; never absorb.56- **`process.exit(0)` without flushing logs/traces.** Stdout is not flushed; telemetry batched; data loss. Use a `shutdown()` coordinator.57- **Bundling a service with webpack/rollup.** Usually unnecessary. Node runs `.js`/`.ts` (with tsx/ts-node) directly. Bundling can break source maps and native modules.58- **`--experimental-*` flags in production.** Experimental means it may change or crash. Pin to stable.59- **Sharing state across `cluster` workers via filesystem or globals.** Use Redis or a real shared store.60- **Ignoring `process.memoryUsage().rss` vs. `heapUsed`.** RSS includes native allocations; heap is only V8. Both matter.61- **Logging Buffers or full objects.** Massive log lines, PII leaks, I/O cost. Redact and truncate.62- **Using `child_process.exec` with user input.** Shell injection. Use `execFile` with argv.6364## Diagnostic Workflow6566### Event-loop lag spike671. `perf_hooks.monitorEventLoopDelay()` in prod continuously.682. Correlate lag spikes with request rate, GC pauses, and specific endpoints.693. Capture CPU profile for the spike window (`--cpu-prof` or `0x` flamegraph).704. Look for long sync functions. Move them to worker threads.7172### Memory leak731. Stable load test; RSS and heap over time via `process.memoryUsage()`.742. Two heap snapshots (baseline + after 10 min load). Compare retainers.753. Common suspects: closures, module-level Maps, unremoved listeners, pending timers.764. `--heap-prof` for sampling heap profile.7778### CPU pegged at 100%791. `--cpu-prof` or `0x` flamegraph during the load.802. Look for JSON parse/stringify, regex, crypto sync calls.813. Measure event-loop lag; a CPU-bound service without lag is probably I/O-bound downstream.8283### Throughput regression841. Diff of event-loop utilization, GC time, and libuv thread pool saturation.852. Measure before and after a suspected commit with the same load profile (k6, autocannon).863. Node's `--trace-gc` for GC behavior.8788## Runtime Defaults (2026 baseline)8990- Runtime: Node 22 LTS (or Node 20 LTS minimum); Bun where validated.91- Flags: `--enable-source-maps` in production; `--max-old-space-size` set from cgroup limit; `--heapsnapshot-near-heap-limit=3` for post-mortem leak analysis; avoid `--experimental-*`.92- Environment: `NODE_ENV=production`, `UV_THREADPOOL_SIZE` default unless measured.93- Observability: OpenTelemetry SDK for Node with auto-instrumentation of http, fs, dns, net, pg, mysql, redis; pino for logs; `prom-client` for metrics; `perf_hooks.monitorEventLoopDelay` for lag.94- HTTP: `undici` for client control; Fastify for server.95- Streams: Web Streams for edge, Node streams for traditional; `stream/promises.pipeline` for composition.96- Workers: `piscina` for worker thread pools.97- Shutdown: SIGTERM handler with deadline-bounded drain of HTTP server, DB pools, queue consumers, trace/log exporters.9899## Deliverables Contract100101- Process lifecycle: SIGTERM handler that drains HTTP, DB, and queue connections within `terminationGracePeriodSeconds`.102- Event-loop lag monitoring in production with SLO alert.103- Heap and RSS monitoring; alert when heap > 80% of limit.104- CPU profiling and heap snapshot tooling documented in runbook.105- Worker thread pool for any CPU-bound work >5ms.106- Bounded concurrency on all fan-out; bounded queues on all back-pressure paths.107- Streams with pipeline for all large-payload I/O.108- AsyncLocalStorage for trace/tenant context propagation.109- Pinned runtime (`engines` + Dockerfile).110- Multi-stage Docker, non-root user, explicit heap limit.111- Runbook covering event-loop lag, memory leak, CPU saturation, throughput regression diagnostic playbooks.112113Quality gates: event-loop lag P99 within budget under peak load, no sync crypto/compression/parsing on main thread, no unbounded caches or listeners, all timers cleaned up on shutdown, signal handlers installed, graceful shutdown drains within deadline, logs and traces flushed before exit.