Performance No. 303

Node.js Profiling skill for Claude Code

Flamegraphs and event loop lag, not guesses. Claude reproduces the slowdown with a load test, classifies it as CPU-bound, event-loop blocking, I/O wait or GC pressure using event loop utilization and lag percentiles, then captures the matching profile with Node's built-in flags. It reads flamegraphs by self time, fixes the specific hot path, and reports before and after p99 latency from the identical load run.

  • namenode-profiling
  • size183 lines · ≈2,150 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Profiles Node.js CPU, event loop, and memory problems with built-in tools first — --cpu-prof and DevTools or speedscope flamegraphs, --inspect, monitorEventLoopDelay and eventLoopUtilization, --heap-prof and heap snapshots, --trace-gc — plus 0x and autocannon for load, and fixes hot paths such as sync I/O, JSON and regex work, and CPU-bound code that belongs in worker threads. Use when a Node service has high CPU, slow p99 latency, event loop lag, growing memory, or when asked to produce or read a flamegraph.

Try asking

  • Our Node API pins one core at 100% under load — profile it and find the hot path
  • p99 latency spikes every few seconds; check whether something blocks the event loop
  • Generate a flamegraph for this Express server while running autocannon against it

Use it when

  • A Node service has high CPU or poor p99 latency
  • Event loop lag or blocked requests are suspected
  • You need a CPU profile or flamegraph you can read
  • Deciding whether work belongs in worker threads

Skip it when

How to check the result

The identical load test shows better req/s and p99, a second profile shows the targeted frame shrank, and event loop lag p99 dropped for blocking issues.

The common mistake it prevents

Profiling a dev server or an idle process, so transpilation, debug logging or nothing at all dominates the flamegraph instead of the real hot path.

~/.claude/skills/node-profiling/SKILL.md

--- name: node-profiling description: Profiles Node.js CPU, event loop, and memory problems with built-in tools first — --cpu-prof and DevTools or speedscope flamegraphs,… license: MIT ---

Done well means: the problem is reproduced under a realistic load, a profile (not a guess) identifies the hot functions or the blocking work, the fix changes that hot path, and the same load test shows improved throughput, latency percentiles, or event loop delay.

Ground rules

  • Profile the production build with production flags (NODE_ENV=production, same Node major, same source map settings). Dev servers, ts-node/tsx transpilation, and debug logging dominate profiles otherwise.
  • Generate load while profiling. An idle process profile shows nothing useful.
  • Classify first: CPU-bound (CPU near 100% of one core), blocked event loop (lag spikes, CPU may be moderate), I/O-bound (low CPU, waiting on DB/HTTP), or memory/GC-bound (heap growing, frequent GC). Each needs a different tool.
  • Change one thing, rerun the identical load, compare percentiles (p50, p95, p99), not averages.
  • Profiling production hosts (inspector, heap snapshots) pauses the process; take the instance out of rotation or get explicit approval first.

Workflow

- [ ] 1. Reproduce: load test that shows the problem (autocannon, k6, or recorded traffic)
- [ ] 2. Classify: CPU, event loop blocking, I/O wait, or memory/GC
- [ ] 3. Capture the matching profile under load
- [ ] 4. Read it: widest self-time frames in your code, or the blocking call stack
- [ ] 5. Fix the hot path; one change at a time
- [ ] 6. Rerun the same load and compare percentiles and profile

1. Reproduce under load

NODE_ENV=production node dist/server.js &
npx autocannon -c 50 -d 30 -p 1 http://localhost:3000/api/orders   # 50 connections, 30 s

Record requests/sec and latency p50/p97.5/p99 from the output as the baseline.

2. Classify

// Add temporarily (or permanently as metrics) to see event loop health
import { monitorEventLoopDelay, performance } from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
let lastELU = performance.eventLoopUtilization();
setInterval(() => {
  const elu = performance.eventLoopUtilization(lastELU);   // delta since last sample
  lastELU = performance.eventLoopUtilization();
  console.log(JSON.stringify({
    lag_p99_ms: +(h.percentile(99) / 1e6).toFixed(1),   // histogram values are nanoseconds
    lag_max_ms: +(h.max / 1e6).toFixed(1),
    elu: +elu.utilization.toFixed(2),                   // 1.0 = event loop always busy
    heap_mb: Math.round(process.memoryUsage().heapUsed / 1048576),
  }));
  h.reset();
}, 5000).unref();
SignalClassificationNext tool
ELU near 1.0, CPU ~100% of a core, throughput flatCPU-bound--cpu-prof flamegraph
Lag p99 spikes (> 50–100 ms) with moderate ELUBlocking synchronous calls--cpu-prof around spikes, --trace-sync-io
Low ELU, low CPU, high latencyWaiting on I/O or poolsTracing/APM spans, DB pool metrics, undici connection limits
Heap grows across load runs, never returnsLeakHeap snapshots (see memory-leak-hunting)
Heap sawtooth with frequent long GC pausesAllocation pressure--trace-gc, allocation sampling (--heap-prof)

3. Capture profiles

CPU profile written on exit (open in Chrome DevTools > Performance, or speedscope.app):

node --cpu-prof --cpu-prof-dir=./profiles dist/server.js
# run the load test, then stop the server so it exits normally

The profile is written only on a normal exit, not when a signal’s default action kills the process. Make sure the server has a handler such as process.on("SIGINT", () => server.close(() => process.exit(0))) before pressing Ctrl+C.

Interactive profiling of a running process:

node --inspect=127.0.0.1:9229 dist/server.js     # never bind the inspector to 0.0.0.0
# Chrome: chrome://inspect -> Open dedicated DevTools -> Performance -> Record during load

Flamegraph alternatives:

ToolUse
npx 0x -- node dist/server.jsOne-command interactive flamegraph HTML
node --prof then node --prof-process isolate-*.logV8 tick profiler, text summary, works anywhere
Linux perf + node --perf-basic-profIncludes native frames (libuv, zlib, crypto)
Clinic.js (clinic doctor/flame)Guided diagnosis; check it supports your Node major before relying on it

Memory and GC:

node --heap-prof dist/server.js                       # sampling allocation profile on exit (.heapprofile)
node --heapsnapshot-signal=SIGUSR2 dist/server.js     # kill -USR2 <pid> writes a .heapsnapshot
node --trace-gc dist/server.js 2>&1 | grep -E "Mark-Compact|Scavenge" | tail

4. Read the profile

  • Sort by self time (bottom-up view). The widest frames in your code or your direct dependencies are the targets; frames like (garbage collector) or (program) point at allocation pressure or native work.
  • In a flamegraph, look for wide plateaus, not tall towers. Tall towers are deep but cheap; wide plateaus are where time goes.
  • Follow a hot frame up to the request handler to know which endpoint pays for it.
  • Async stacks: CPU profiles show synchronous time only. Waiting time does not appear; if the profile is mostly idle while latency is high, the bottleneck is I/O.

5. Common hot paths and fixes

Found in profileFix
JSON.stringify/parse on large payloadsPaginate, trim fields, stream (stream/web, NDJSON), cache serialized output; fast-json-stringify with schemas (Fastify does this)
pbkdf2Sync, scryptSync, bcrypt sync, zlib.*SyncAsync versions (thread pool) and raise UV_THREADPOOL_SIZE if saturated; or worker threads
fs.readFileSync / existsSync in request pathAsync fs, cache at startup
Regex in hot loop or catastrophic backtrackingPrecompile, simplify, bound input length
Logging (console.log, string formatting) under loadAsync structured logger (pino), log less at info level
Deep clones (structuredClone, lodash cloneDeep) per requestAvoid cloning; use immutable patterns
Large array sort/filter chains per requestPrecompute, index with Map, push to DB
Template rendering / SSRCache rendered fragments; stream
Source map support in stack traces (--enable-source-maps) with many errors thrownThrow less in hot paths; errors are expensive to create
CPU-bound work (image processing, parsing, crypto)worker_threads pool (e.g. piscina), or a separate service/queue

Worker pool for CPU-bound tasks:

// main.js
import Piscina from "piscina";
const pool = new Piscina({ filename: new URL("./resize-worker.js", import.meta.url).href });
app.post("/thumb", async (req, res) => res.send(await pool.run(req.body)));

Cluster vs. workers: use multiple processes (cluster, PM2, or container replicas) to use all cores for request handling; use worker threads to keep one process’s event loop free from CPU-heavy tasks.

Gotchas

  • --cpu-prof writes only on clean exit; kill -9 loses it.
  • Profiling overhead: inspector sampling adds a few percent; 0x and --prof are similar. Compare runs captured the same way.
  • Minified or bundled server code makes frames unreadable; profile unminified builds or ensure source maps are loaded in DevTools.
  • UV_THREADPOOL_SIZE (default 4) caps concurrent fs, dns.lookup, crypto, and zlib async work; DNS lookups queueing behind file I/O look like slow HTTP calls.
  • await inside a loop over independent I/O serializes it; that is a latency issue the CPU profile will not show (see async-concurrency).
  • Memory limit: the default heap limit depends on system memory and Node version; set --max-old-space-size explicitly in containers to fit the memory limit with headroom.

Verification

  1. Rerun the identical autocannon/k6 command; report req/s and p50/p99 before and after.
  2. Capture a second profile; the targeted frame’s self time shrank or disappeared.
  3. Event loop lag p99 and ELU improved (for blocking issues).
  4. Tests pass; error rate under load is unchanged or lower.

Report template

**Symptom:** /api/orders p99 1.8 s at 200 rps, CPU 100% on one core
**Classification:** CPU-bound (ELU 0.98, lag p99 340 ms)
**Profile:** 41% self time in JSON.stringify via responseSerializer -> orders with nested audit logs
**Fix:** excluded audit logs from list endpoint, added schema-based serializer
**Result (autocannon -c 50 -d 30):** 410 -> 1,350 req/s; p99 1,820 -> 210 ms; lag p99 340 -> 18 ms

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc