"Without measurement, every optimization is a guess. Most Node performance issues are not where your intuition expects."
The Three Things You Profile
- CPU — where the JS spends time. Spotting head-of-line blocking, hot functions, sync work that should be async.
- Memory — what's allocated, what's retained, who's leaking. Memory leaks cause restarts; high allocation rates cause GC pauses.
- I/O / Event loop — what's blocking the loop. Spotting microtask starvation, slow file/DB calls, libuv thread pool saturation.
The tools differ for each. The instinct that ties them: always measure before you optimize. The slow path is almost never where you'd guess.
--inspect — Chrome DevTools for Node
Run Node with --inspect:
node --inspect server.mjs
# Listening for the inspector on 127.0.0.1:9229
Open chrome://inspect in Chrome. Click inspect. You get the full DevTools UI: Performance recorder, Memory profiler, heap snapshots, the works. It's the same tool you use on web pages, pointed at your Node process.
- Performance tab — start recording, run your slow operation, stop. Get a flame graph of where time went, function by function.
- Memory tab — take a heap snapshot, run for a while, take another. Diff to find what was retained.
- Profiler tab — sampling CPU profile, lower overhead than the Performance tab's tracing.
clinic.js — Diagnose Without Touching DevTools
clinic.js bundles four diagnostic tools as one CLI:clinic doctor— runs your app under load, identifies the likely bottleneck category (CPU, I/O, GC, event loop).clinic flame— CPU flame graph, no DevTools needed.clinic bubbleprof— async operation timeline. Shows what your async tasks were doing, in what order.clinic heapprofiler— memory allocation flame graph.
npx clinic doctor -- node server.mjs runs the doctor while a load tool (clinic comes with autocannon for HTTP) hammers your endpoints. The output is an HTML report you open in a browser. For 80% of "my server is slow" investigations, doctor's diagnosis is enough to point at the problem.What You'll Actually Find
Common Node performance bugs, in order of frequency:
- Sync work on the main thread — heavy JSON parsing, sync crypto, regex on huge strings, nested loops on large arrays. The p99/p50 gap (Track 1) is the signature. Move to worker_threads.
- Database queries that should be one query — N+1 patterns where each item triggers another query. Diagnose with DB logs; fix by joining or batching.
- libuv thread pool saturation — too many concurrent fs/dns/crypto operations. Default pool is 4 threads; bump with
UV_THREADPOOL_SIZE. - Memory leaks via global state or unbounded caches — heap snapshots showing growth between successive samples. Fix by adding TTLs or LRU bounds.
- Excessive object allocation — creating millions of short-lived objects in a hot loop causes GC pauses. Refactor to reuse objects or use typed arrays.
Measuring in Production
Profiling locally is fine; the real performance picture comes from production. Modern Node ships node:perf_hooks and integrates with OpenTelemetry. Wire your service to export traces to Tempo, Jaeger, or any OTLP-compatible backend. Spot the slow span; profile it locally; fix it. The two-step: production observability tells you WHAT is slow; local profiling tells you WHY.