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.
When agents use itUse 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.
Install
Copilot (VS Code, Visual Studio, Copilot CLI and github.com) reads skills from the repository — commit them so the whole team gets them.
npx skills add AGCO-Global/org-skills --skill nodejs-performance -a github-copilot
# or with the org installer (adds .github/skills/nodejs-performance):
npx -y github:AGCO-Global/org-skills add skill nodejs-performance
Uses the open skills CLI. Works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, OpenCode, Windsurf and 60+ others — it asks which agent to install into.
Installs the backend-nodejs-skills plugin, which bundles all Backend / Node.js skills and keeps them updated.
/plugin marketplace add AGCO-Global/org-skills
/plugin install backend-nodejs-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill nodejs-performance -a claude-code
Use Download .zip above, then upload it under Customize → Skills → Upload skill. Team and Enterprise admins can sync this repo as a plugin marketplace instead.
Installs into .agents/skills/, which Codex reads.
npx skills add AGCO-Global/org-skills --skill nodejs-performance -a codex
Installs into Cursor's skills folder.
npx skills add AGCO-Global/org-skills --skill nodejs-performance -a cursor
Installs into Gemini CLI's skills folder.
npx skills add AGCO-Global/org-skills --skill nodejs-performance -a gemini-cli
Any tool with rules, instructions or custom prompts: use Copy SKILL.md above and paste it in. It is plain Markdown.
Commands use your normal git sign-in to GitHub, so they work while the repository is private. Node.js 20+ required.
Skill contents
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
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
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.
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/promisespipeline(); 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.
---
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.