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.
- name
node-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
- Memory keeps growing and you need retainer analysis — use memory-leak-hunting
- Slowness comes from too many database queries — use n-plus-one-queries
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.
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();
| Signal | Classification | Next tool |
|---|---|---|
| ELU near 1.0, CPU ~100% of a core, throughput flat | CPU-bound | --cpu-prof flamegraph |
| Lag p99 spikes (> 50–100 ms) with moderate ELU | Blocking synchronous calls | --cpu-prof around spikes, --trace-sync-io |
| Low ELU, low CPU, high latency | Waiting on I/O or pools | Tracing/APM spans, DB pool metrics, undici connection limits |
| Heap grows across load runs, never returns | Leak | Heap snapshots (see memory-leak-hunting) |
| Heap sawtooth with frequent long GC pauses | Allocation 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:
| Tool | Use |
|---|---|
npx 0x -- node dist/server.js | One-command interactive flamegraph HTML |
node --prof then node --prof-process isolate-*.log | V8 tick profiler, text summary, works anywhere |
Linux perf + node --perf-basic-prof | Includes 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 profile | Fix |
|---|---|
JSON.stringify/parse on large payloads | Paginate, trim fields, stream (stream/web, NDJSON), cache serialized output; fast-json-stringify with schemas (Fastify does this) |
pbkdf2Sync, scryptSync, bcrypt sync, zlib.*Sync | Async versions (thread pool) and raise UV_THREADPOOL_SIZE if saturated; or worker threads |
fs.readFileSync / existsSync in request path | Async fs, cache at startup |
| Regex in hot loop or catastrophic backtracking | Precompile, simplify, bound input length |
Logging (console.log, string formatting) under load | Async structured logger (pino), log less at info level |
Deep clones (structuredClone, lodash cloneDeep) per request | Avoid cloning; use immutable patterns |
Large array sort/filter chains per request | Precompute, index with Map, push to DB |
| Template rendering / SSR | Cache rendered fragments; stream |
Source map support in stack traces (--enable-source-maps) with many errors thrown | Throw 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-profwrites only on clean exit;kill -9loses it.- Profiling overhead: inspector sampling adds a few percent; 0x and
--profare 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.awaitinside 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-sizeexplicitly in containers to fit the memory limit with headroom.
Verification
- Rerun the identical autocannon/k6 command; report req/s and p50/p99 before and after.
- Capture a second profile; the targeted frame’s self time shrank or disappeared.
- Event loop lag p99 and ELU improved (for blocking issues).
- 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.