Profiling & Heap Snapshots

advanced nodejs performance profiling memory

“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.

Memory Leak Hunt
1. Take snapshot A (baseline, app idle)
↓ run suspect workload for 5 min
2. Take snapshot B
3. DevTools: "Comparison" view, sort by Delta. What grew?
↓ click a suspicious class
4. "Retainers" panel shows what's holding the reference

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 paths
  • clinic 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), Array with 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 --inspect on a non-public port and attach when needed.
  • Use process.memoryUsage() and perf_hooks to log metrics continuously, profile deeply only when alerts fire.
  • For really gnarly issues, take a snapshot in prod, download it, analyze locally in DevTools.