Node.js Profiling skill for Codex
Classify the slowdown, then profile the right thing. Codex first decides whether a slow Node service is CPU-bound, blocking the event loop, waiting on I/O or leaking memory, using event loop utilisation and delay histograms. It then captures the matching profile under realistic load with --cpu-prof, the inspector, heap profiles or 0x, names the hot function and line, applies a targeted fix, and re-runs the same load to prove the gain.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- node-profiling Installs to ~/.agents/skills/node-profiling/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install node-profiling The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project node-profiling Installs to .agents/skills/node-profiling/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/node-profiling.zipunzip -o node-profiling.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) node-profiling PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $node-profiling, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Profiles Node.js services and scripts - CPU profiles with --cpu-prof and the inspector, heap profiles and snapshots, event loop delay and utilisation with perf_hooks, flame graphs with 0x or Clinic.js - and turns the hot frames into fixes such as moving sync work off the loop, caching, streaming or worker threads. Use when a Node API has high latency or CPU, the event loop lags, throughput drops under load, memory climbs, or someone asks to profile a Node, Express, Fastify or NestJS process.
Use it when
- A Node service shows high CPU, rising latency or falling throughput under load
- Event loop delay or utilisation is high
- You need a CPU or allocation profile of a Node script or server
Use something else when
- Memory grows without bound and you need retained-object analysis: use memory-leak-hunting
- CPU is low but requests are slow because of the database: use database-performance-triage
How to check the result
Re-run the identical load and compare req/s, p99 latency, event loop utilisation and the hotspot's share of CPU samples before and after.
The mistake it prevents
Profiling an idle process or a dev build, which produces a profile of the wrong code path and points at nothing useful.
Done well means: the symptom is classified (CPU-bound, event-loop blocked, waiting on I/O, or memory) before any profiler runs, the profile is captured under realistic load, the hot path is named down to a function and line, and the fix is proven with the same load and the same measurement.
Classify the symptom first
| Signal | Likely class | Tool |
|---|---|---|
| Process CPU near 100% of one core, latency rises with load | CPU-bound on the main thread | --cpu-prof, flame graph |
| Event loop delay p99 > ~50 ms, CPU moderate | Blocking sync calls, big JSON, regex, GC pauses | ELU + delay histogram, CPU profile |
| CPU low, latency high | Waiting on DB, HTTP, pool, locks | Tracing / APM, not a CPU profiler: $database-performance-triage, $latency-budgets |
| RSS or heap grows steadily | Leak or unbounded cache | Heap snapshots: $memory-leak-hunting |
| Saw-tooth heap with long pauses | GC pressure from allocation churn | --heap-prof, --trace-gc |
Measure the loop before anything else; it is cheap enough for production:
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
let last = performance.eventLoopUtilization();
setInterval(() => {
const elu = performance.eventLoopUtilization(last);
last = performance.eventLoopUtilization();
console.log(JSON.stringify({
elu: +elu.utilization.toFixed(3),
p50ms: h.percentile(50) / 1e6, p99ms: h.percentile(99) / 1e6, maxms: h.max / 1e6,
}));
h.reset();
}, 10_000).unref();
ELU near 1.0 means the loop is saturated: adding concurrency will not help, only less CPU per request or more processes.
Workflow
- [ ] 1. Read AGENTS.md / package.json: Node version, start command, framework
- [ ] 2. Reproduce with a load generator against a production build (NODE_ENV=production)
- [ ] 3. Capture ELU, loop delay, CPU and memory baseline under that load
- [ ] 4. Capture the right profile for the class (below) for 30-60 s of steady load
- [ ] 5. Find the top self-time and top inclusive frames in your code
- [ ] 6. Fix one hotspot; re-run the identical load; compare p50/p99 and req/s
Load: npx autocannon -c 50 -d 60 http://localhost:3000/api/orders (needs network to
install). Or reuse the project’s k6 scripts ($load-testing). Never profile against
production traffic or a shared environment without the user’s approval.
CPU profile
# Writes a .cpuprofile on exit; open in Chrome DevTools > Performance or speedscope
node --cpu-prof --cpu-prof-dir=./profiles --cpu-prof-interval 500 dist/server.js
# then: run the load, stop with Ctrl+C (SIGINT) so the profile is flushed
# Attach to a running process instead
node --inspect=127.0.0.1:9229 dist/server.js # then chrome://inspect > Profiler
- Keep
--inspectbound to 127.0.0.1. Binding to 0.0.0.0 gives anyone on the network code execution. - In containers, start with
--cpu-profand stop the process gracefully;kill -9loses the profile. - Built-in V8 tick profiler (no DevTools needed):
node --prof app.js, thennode --prof-process isolate-*.log > ticks.txt.
Flame graph in one step: npx 0x -o dist/server.js (needs network to install). Clinic.js
(clinic doctor, clinic flame) gives guided diagnosis but sees little maintenance and
may lag new Node releases; prefer --cpu-prof when it misbehaves.
Reading the profile
- Sort Bottom-Up by self time. Frames from
node_modulesare not exempt: a slow serializer or validator called per request is your problem too. - Common hot spots:
JSON.parse/JSON.stringifyon large payloads, synchronousfs.*Sync,crypto.pbkdf2Sync/scryptSync, regex with catastrophic backtracking,Array.prototype.findinside loops (O(n²)), logging with pretty-printing in production, schema compilation per request instead of once. (garbage collector)above ~10% of samples means allocation churn: use a heap profile.(program)/(idle)dominant means the CPU profiler is the wrong tool; the time is spent waiting.
Heap and GC
node --heap-prof --heap-prof-dir=./profiles dist/server.js # sampling allocation profile
node --trace-gc dist/server.js 2>&1 | head -50 # GC events and pauses
node --heapsnapshot-signal=SIGUSR2 dist/server.js # kill -USR2 <pid> writes a snapshot
Allocation profiles answer “who allocates most”; heap snapshots answer “what is retained”. Snapshots pause the process and can double memory use; never take them on a production instance near its memory limit without the user’s go-ahead.
Fix patterns
| Hot path | Fix |
|---|---|
| Sync crypto, compression, image work | Async APIs (crypto.scrypt, zlib.gzip), or worker_threads pool (Piscina) |
| Large JSON per request | Paginate, stream (JSON lines), select fewer fields, cache serialised output |
| Repeated computation on same inputs | Memoise with a bounded LRU; precompute at startup |
| Validation/schema compile per call | Compile once at module load (Ajv, Zod schemas as constants) |
| Logging cost | Structured logger (pino) with async transport; no pretty-printing in production |
| Regex backtracking | Rewrite with anchors and atomic structure; bound input length |
| Loop saturated after all fixes | More processes (cluster, containers) behind the load balancer |
Hard rules
- Profile production builds with production
NODE_ENV; dev mode changes frameworks’ hot paths (React SSR, Express view caching). - Same Node version as production: V8 optimisations differ between majors.
- A CPU profile of an idle process proves nothing. Drive load during the capture.
- Remove
--inspectflags and profiling hooks from production start scripts after the investigation unless the team decides to keep them deliberately.
Verification
Re-run the identical load for the same duration and compare:
| Metric | Before | After |
|---|---|---|
| req/s | 1,850 | 3,400 |
| p99 latency | 420 ms | 95 ms |
| ELU | 0.98 | 0.61 |
| loop delay p99 | 180 ms | 22 ms |
| hotspot share | buildReport 41% self | 3% |
If throughput did not change, the fixed function was not on the critical path, or the bottleneck moved (DB, pool); re-classify before the next change.
Report template
## Node profile: <service> (<Node version>, <load profile>)
Class: CPU-bound on main thread (ELU 0.98, loop delay p99 180 ms)
Hotspots (self time, CPU profile 60 s):
1. src/reports/build.ts:88 `formatRows` 41% - JSON stringify per row
2. node_modules/ajv compile 12% - schema compiled per request
Fixes applied: ... Results: <table above>
Profiles saved: ./profiles/<file>.cpuprofile What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Node.js Profiling"
short_description: "Classify the slowdown, then profile the right thing"
default_prompt: "$node-profiling Our Express API pegs one CPU at 100% under load, profile it and find the hotspot" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.