---
name: nodejs-performance
description: Diagnose and fix Node.js back-end performance problems — event loop blocking and lag, high latency and low throughput, CPU hot paths, memory leaks and OOM kills, stream backpressure, JSON serialization cost, slow queries and pool exhaustion, caching, HTTP keep-alive and scaling in containers. Use this whenever the user says a Node/TypeScript API is slow, p99 latency spikes, CPU sits at 100%, memory keeps growing, the pod gets OOMKilled, "JavaScript heap out of memory", event loop lag or utilization alerts fire, requests time out under load, or asks about clinic.js, 0x, --cpu-prof, --inspect, heap snapshots, autocannon, k6, perf_hooks, worker_threads, cluster, PM2, UV_THREADPOOL_SIZE, --max-old-space-size or undici agents. Also apply it when reviewing Node.js code for hot-path issues such as sync I/O, unbounded caches, large JSON.parse/stringify, N+1 queries or missing timeouts in server.ts or handlers.
metadata:
  technology: Node.js
  type: performance
---

# Node.js Performance

Node is fast until something occupies the single JavaScript thread; then every request waits. Measure under realistic load, find the one bottleneck (CPU on the loop, waiting on I/O, memory/GC, or downstream), fix that, and re-measure. Never tune GC flags or add clustering before you have a profile.

## 1. Classify the problem with numbers

| Signal | Collect | Meaning |
|---|---|---|
| Event loop delay | `perf_hooks.monitorEventLoopDelay()` p99 | > ~50–100 ms means something blocks the loop |
| Event loop utilization | `performance.eventLoopUtilization()` | Near 1.0 = CPU-bound on the main thread; low ELU + high latency = waiting on I/O/downstream |
| CPU per process | container metrics | ~100% of one core with low ELU headroom = loop saturated |
| Heap used vs limit | `process.memoryUsage()`, `v8.getHeapStatistics()` | Sawtooth = normal GC; rising floor = leak |
| Latency by span | OpenTelemetry traces | Which dependency or step dominates |
| Pool wait | ORM/driver pool metrics | Requests queueing for DB connections |

```ts
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 20 }); h.enable();
let last = performance.eventLoopUtilization();
setInterval(() => {
  const elu = performance.eventLoopUtilization(last); last = performance.eventLoopUtilization();
  log.info({ elu: elu.utilization, lagP99ms: h.percentile(99) / 1e6 }); h.reset();
}, 10_000).unref();
```

## 2. Reproduce and profile

- Load test with **autocannon** (quick, single endpoint) or **k6** (scenarios, ramping, thresholds) against a production build with production-like data volume. Record p50/p95/p99, throughput and error rate as the baseline.
- CPU: `node --cpu-prof dist/server.js` (open `.cpuprofile` in DevTools), **0x** flame graphs, or `node --inspect` + Chrome DevTools Performance. **clinic.js** (doctor/flame/bubbleprof) gives a guided first look; check it supports your Node version.
- Memory: `--heapsnapshot-signal=SIGUSR2` or `v8.writeHeapSnapshot()`; `--heap-prof` for allocation sampling.
- Run the profiler under load, not idle; source maps on (`--enable-source-maps`) so frames map to TS.

## 3. Triage table

| Symptom | Likely cause | Check | Fix |
|---|---|---|---|
| All endpoints slow together, high ELU | Loop blocked by sync work | CPU profile: `JSON.parse`, regex, `*Sync`, crypto, big loops | Stream/chunk, move to worker_threads, async APIs |
| p99 spikes, periodic | GC pauses or large allocations | `--trace-gc`, heap sampling | Reduce allocation, avoid giant arrays/strings; paginate |
| Latency high, ELU low | Waiting on DB/HTTP/pool | Traces, pool wait metrics | Fix queries/indexes, size pool, parallelize independent calls |
| Slow `bcrypt`/`fs`/`zlib`/`dns.lookup` under load | libuv threadpool (default 4) saturated | Concurrency vs `UV_THREADPOOL_SIZE` | Raise `UV_THREADPOOL_SIZE`, cache DNS, reduce concurrent heavy ops |
| Memory floor rises until OOM | Leak: unbounded cache, listeners, closures, global maps | Compare 3 heap snapshots over time | Bound caches, remove listeners, fix retention |
| RSS high, heap normal | Buffers, native memory, streams not drained | `memoryUsage().arrayBuffers/external` | Backpressure with `pipeline`, stream instead of buffering |
| Outbound calls slow, many sockets | No keep-alive / new client per request | Socket counts, TLS handshakes in traces | Shared agent/dispatcher with keep-alive |
| 502s behind load balancer | Server `keepAliveTimeout` shorter than LB idle timeout | Compare timeouts | Set `server.keepAliveTimeout` above LB idle timeout, `headersTimeout` above that |
| Throughput flat as pods scale | Downstream (DB) is the bottleneck | DB CPU, connections, locks | Query/index work, caching, read replicas |

## 4. CPU-heavy work

- Anything over a few milliseconds of synchronous CPU per request (image processing, PDF, large CSV/JSON transforms, hashing loops) goes to **worker_threads** via a pool (`piscina`) or to a separate queue-fed worker service.
- Break unavoidable in-loop work into chunks with `setImmediate` yields only as a stopgap.
- Beware catastrophic regex backtracking on user input; bound input sizes and use linear-time patterns.

```ts
import Piscina from 'piscina';
const pool = new Piscina({ filename: new URL('./resize.worker.js', import.meta.url).href });
app.post('/thumbnails', async (req) => pool.run({ key: req.body.key }));
```

## 5. Memory leaks

Common causes: module-level `Map`/object caches without eviction, event listeners added per request (`MaxListenersExceededWarning` is a clue), closures capturing request objects in long-lived callbacks, timers never cleared, unbounded in-memory queues, `AsyncLocalStorage` stores holding large objects.

Method: take a snapshot after warm-up, run load, take two more; compare "Objects allocated between snapshots" and follow retainers to the root. Set `--max-old-space-size` explicitly to roughly 70–80% of the container memory limit so V8 collects before the kernel kills the process.

## 6. I/O, streams and serialization

- Stream large payloads with `stream/promises` `pipeline()`; it propagates errors and respects backpressure. Never `await res.text()` a multi-MB body just to forward it.
- Honour `write()` returning `false` (wait for `'drain'`) when writing manually.
- JSON is often the top CPU cost: Fastify response schemas compile serializers (`fast-json-stringify`) and drop unlisted fields; return only needed fields; paginate lists; avoid `JSON.parse(JSON.stringify(x))` cloning (use `structuredClone` or don't clone).
- Compression (gzip/brotli) is CPU work: offload to the reverse proxy/CDN where possible.

## 7. Database, caching, outbound HTTP

- Kill N+1 (ORM includes/joins or batching with DataLoader), add indexes from `EXPLAIN ANALYZE`, select only needed columns, paginate with keysets.
- Pool size is per process: `replicas × processes × pool` must stay below DB connection limits; pooling proxy for serverless.
- Parallelize independent awaits with `Promise.all`; cap fan-out concurrency (`p-limit`).
- Caching: in-process LRU (`lru-cache` with `max` and `ttl`) for hot, small, per-instance data; **Redis** for shared or larger data and cross-instance invalidation. Add stampede protection (request coalescing, jittered TTLs). Every cache has a bound and an invalidation story.
- Outbound: reuse one keep-alive agent/undici dispatcher per upstream (`new Agent({ keepAliveTimeout, connections })`), set timeouts, and don't create clients per request.

## 8. Scaling and runtime flags

- In containers, prefer **one Node process per container** and scale horizontally; give it ~1 CPU. Use `cluster`/PM2 only on VMs or when a pod must use several cores and you accept the extra memory and complexity.
- Keep Node on the current LTS; upgrades often bring V8 and undici gains for free.
- GC flags (`--max-semi-space-size` for allocation-heavy services, `--max-old-space-size`) only with before/after measurements; change one at a time.

## Deliverable format

```
## Baseline      load profile, p50/p95/p99, RPS, error rate, ELU, heap, tool used
## Diagnosis     triage row matched, evidence (profile frames, snapshot retainers, traces)
## Fixes         ordered by impact; minimal code/config diff for each
## Result        same load test re-run; before/after table
## Guardrails    alerts/metrics added (loop lag, ELU, heap, pool wait) and load test in CI
```

## Anti-patterns to reject

- Tuning or adding clustering/replicas without a profile.
- `*Sync` fs/crypto/zlib calls, big `JSON.parse` or regex on user input in request handlers.
- Unbounded `Map` caches, per-request listeners, timers without `clearTimeout`/`unref`.
- Buffering whole files or responses in memory instead of streaming.
- New HTTP client, DB pool or Redis connection per request.
- `cluster` inside Kubernetes pods by default; `--max-old-space-size` larger than the container limit.
- Measuring with the dev server, `tsx` watch mode or an empty database.
