“Our API got slow” or “memory keeps climbing until OOM” are vague. Profiling turns them into “this regex is 60% of CPU time” or “we’re retaining 800k of these objects.”
There are three tools we’ll use: --prof for CPU, heap snapshots for memory, and clinic.js when we want pretty graphs without learning V8 internals.
CPU profiling with —prof
Run Node with --prof and it dumps a V8 tick log to a file like isolate-0xNNNN-v8.log. Then we process it into something readable.
# 1. Run app under load (use autocannon, k6, ab, etc. to generate traffic)
node --prof server.js
# 2. Stop the process, find the log
ls isolate-*-v8.log
# 3. Process into a flat profile
node --prof-process isolate-0x10800000-v8.log > profile.txt
The output looks like:
[Summary]:
ticks total nonlib name
1234 45.2% 60.1% JavaScript
412 15.1% 20.0% C++
...
[JavaScript]:
ticks total nonlib name
389 14.2% 18.9% LazyCompile: *parseRequest /app/server.js:42
201 7.3% 9.7% LazyCompile: *hashPassword /app/auth.js:18
In simple language: each “tick” is a sample of “what was the CPU doing right now?” The function with the most ticks is the hot spot.
We’re looking for surprises. “Why is JSON.parse 40% of our time?” or “Why does bcrypt show up — isn’t that supposed to be async?”
CPU profiling via DevTools (nicer)
Run with --inspect, attach Chrome DevTools, go to the Performance tab, hit record, run the load, stop. We get a flame graph with function names, time spent, and we can drill in.
This is usually friendlier than reading --prof-process output. Same data, prettier.
Heap snapshots — for memory issues
A heap snapshot is “freeze the current state of memory, list every object.” We take two snapshots — one before something, one after — and diff them to find what got allocated but never freed.
How to take one:
import { writeHeapSnapshot } from 'node:v8';
// Programmatic
const file = writeHeapSnapshot();
console.log('snapshot saved to', file);
Or via DevTools: attach with --inspect, go to the Memory tab, click Take snapshot.
The retainers chain is the magic part. It tells us “this 50MB Map is retained by globalCache in cache.js:12.” Now we know exactly which line to fix.
Clinic.js — easy mode
Writing autocannon scripts and reading flame graphs is fine, but clinic.js packages this nicely.
npm i -g clinic autocannon
# CPU + event loop analysis
clinic doctor -- node server.js
# In another terminal: autocannon -c 100 http://localhost:3000
# CTRL+C the server, browser opens with a report
Three sub-commands worth knowing:
clinic doctor— high-level “is the bottleneck CPU, I/O, event loop, or GC?”clinic flame— flame graph of CPU hot pathsclinic bubbleprof— async operation timing (shows where awaits stall)
doctor is the right starting point — it tells us which other tool to reach for next.
What to look for
- CPU profile — any single function dominating? Often a regex, JSON serialization, sync crypto, or accidentally-quadratic code.
- Heap snapshot diff — any class with thousands of instances that should be temporary? Look for
Closure,(string),Arraywith huge retained size. - Event loop lag — clinic.doctor flags it red. Means we’re doing too much sync work between I/O.
- GC pressure — if “GC” is a big slice in the CPU profile, we’re allocating too aggressively. Reuse buffers, avoid hot-path
.map().filter().reduce()chains.
Production profiling
Don’t run --prof 24/7 — it has overhead. Instead:
- Enable
--inspecton a non-public port and attach when needed. - Use
process.memoryUsage()andperf_hooksto log metrics continuously, profile deeply only when alerts fire. - For really gnarly issues, take a snapshot in prod, download it, analyze locally in DevTools.