skills/node-memory-leak-hunting/SKILL.md
Use when a Node.js process is growing memory unbounded in production, hits OOM, restarts on schedule, or shows monotonic RSS in Grafana. Triggers: heap snapshot diff (Comparison mode in Chrome DevTools), v8.writeHeapSnapshot, --heapsnapshot-signal=SIGUSR2, clinic doctor / clinic heap, Allocation sampling vs Allocation instrumentation timeline, --max-old-space-size, retainers / dominators, EventEmitter listener leak, closures over big objects, Buffer.allocUnsafe, intern caches that never evict. NOT for CPU profiling (use clinic doctor for the smoke signal then a CPU-specific skill), Go pprof, browser-side memory leaks, or worker_threads-only debugging.
npx skillsauth add curiositech/windags-skills node-memory-leak-huntingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
A leak in Node.js is "RSS goes up, never comes down." V8 garbage-collects what it can prove is unreachable; a leak means something is still reachable that you didn't expect. The single most powerful tool, recommended by the Node.js diagnostics docs and the practitioner field, is the two-snapshot diff in Chrome DevTools' Memory tab. (Node.js — Using Heap Snapshot)
The compressed playbook:
1. Confirm the leak (Grafana shows monotonic RSS over hours).
2. Snapshot before load.
3. Drive load that exercises the suspect path.
4. Snapshot after.
5. Open both in DevTools → Memory tab → Comparison mode.
6. Sort by "Delta" desc. The retained, growing constructor at the top is your leak.
Jump to your fire:
signal SIGKILL, exit 137) on Kubernetes pods.Heap pressure can look like a leak without being one. Before doing snapshot work, rule out the usual:
| Smell | Likely cause | Real fix |
|---|---|---|
| RSS climbs to a ceiling, then plateaus | V8 reaching --max-old-space-size. Not a leak. | Raise the limit; revisit if growth resumes |
| RSS climbs after each request, never falls | Genuine leak — proceed. | Heap snapshot diff |
| RSS climbs for an hour, then drops sharply | Deferred GC of a large old generation. Not a leak. | Watch over a longer window |
| RSS jumps on a recurring schedule (cron, batch job) | A periodic job allocating a lot transiently | Look at the job, not the long-lived state |
| RSS flat, but heap_used climbing | True leak; OS hasn't returned pages yet | Heap snapshot diff |
Wire process.memoryUsage() to your metrics exporter (heap_used, heap_total, rss, external, arrayBuffers). See grafana-dashboard-builder for the panel and alert.
In Node 12+, signal-based snapshots avoid the need for an admin endpoint:
node --heapsnapshot-signal=SIGUSR2 index.js
# Then from another shell:
kill -USR2 <pid>
# Snapshot written to ./Heap-<pid>-<timestamp>.heapsnapshot
(Node.js docs)
For programmatic capture (Node 11.13+), use v8.writeHeapSnapshot():
import { writeHeapSnapshot } from 'node:v8';
import { resolve } from 'node:path';
// Authenticated admin endpoint — minimal middleware.
app.post('/_admin/heapsnap', requireAdmin, (req, res) => {
const path = resolve(`/tmp/heap-${process.pid}-${Date.now()}.heapsnapshot`);
writeHeapSnapshot(path); // Synchronous; pauses the process for ~seconds at multi-GB heaps.
res.json({ path });
});
Snapshots are big. Multi-GB heaps produce multi-GB files. Practitioner pattern: write to a mounted S3 (or equivalent) volume rather than local disk. (Halodoc — Find & Fix Node.js Memory Leaks) Snapshotting also pauses the event loop while V8 walks the heap; on a 4 GB heap expect a few seconds of pause. Consider taking the snapshot from a replica or canary, not the busy primary.
The technique the Node.js docs spell out, paraphrased: (node-heap-snapshot)
1. Let the process bootstrap completely.
2. Snapshot 1.
3. Drive the suspect path (curl loop, k6 script, real traffic).
4. Snapshot 2.
5. In Chrome DevTools → Memory tab, load both .heapsnapshot files.
6. Select snapshot 2; in the dropdown, choose "Comparison".
7. Sort by "Delta" descending. The constructor with a large positive #New
AND a large positive #Delta — and zero #Deleted — is your leak candidate.
The Node.js docs frame it: "Open the older snapshot first, then load the newer one. Switch the view from Summary to Comparison. Look for large positive deltas." (node-heap-snapshot)
A useful three-snapshot variant for slow leaks: snapshot, run load, snapshot, run more load, snapshot. Compare 1↔2 and 2↔3. The constructor that grows in both diffs is the leak. (One-shot diffs sometimes catch transient allocations that look like leaks but aren't retained after a few more GCs.)
Once you've found the leaking constructor, click into instances and read Retainers (bottom panel). Retainers are the chain of references keeping the object alive. Read up the chain:
MyHandler @ 0x1234
└─ in [callbacks] of EventEmitter @ 0x5678
└─ in [errorHandlers] of Server @ 0x9abc
└─ in (closure) of express()
└─ in (Global) — the root
You're looking for the unexpected reference — the moment the chain becomes "oh, that's still holding it." Common shapes are in the next section.
The Dominators view answers a different question: "if this object went away, how much would I free?" Useful when one Map or array is hoarding a long tail of small objects.
When the production process can't tolerate the pause of a full snapshot, the Allocation Sampling profiler is safe to leave on: (Node.js — Using Heap Profiler)
node --inspect index.js
In Chrome DevTools → Memory → Allocation sampling → Start. Statistically samples allocations by stack trace; low overhead, production-safe. (node-heap-profiler) Doesn't tell you what's retained (snapshots do that) but tells you where the allocation pressure is coming from.
For a programmatic, file-output equivalent, the node --heap-prof flag (V8 sampling heap profiler) writes a .heapprofile you can open in DevTools too:
node --heap-prof --heap-prof-interval=512000 index.js
(--heap-prof-interval is the average bytes between samples; default 512 KB.)
These are the constructor names you'll typically see on top of the diff:
// Every request adds one. Never removed.
function handler(req, res) {
emitter.on('event', () => res.send('ok')); // accumulates per-request closures
}
Symptom in heap diff: explosive growth of [ListenerNode] / Function retained by an EventEmitter's _events map. Often paired with the runtime warning "MaxListenersExceededWarning".
Fix: emitter.once, or removeListener after handling. Better: don't subscribe per-request to a long-lived emitter.
const cache = new Map();
function get(key) {
if (cache.has(key)) return cache.get(key);
const v = expensive(key);
cache.set(key, v); // Map grows forever.
return v;
}
Symptom in heap diff: the leaking constructor IS your Map / Object, with Distance increasing by the count of unique keys.
Fix: an LRU with a hard cap (lru-cache), WeakMap if keys are objects you want GC'd when callers drop them.
function load() {
const big = readFileSync('big.json'); // 200 MB
const small = JSON.parse(big).meta; // tiny
return () => small; // closure retains `big` AND `small`!
}
Symptom: (closure) in retainer chain pointing at a constructor that is far larger than what the function "needs."
Fix: narrow the closure: function load() { const big = readFileSync('big.json'); const small = JSON.parse(big).meta; return makeFn(small); } where makeFn is at module scope.
process.memoryUsage().external is climbing while heap_used is flat. V8 manages the heap; native buffers are tracked separately. Common cause: cached Buffers or ArrayBuffers held by user code.
Fix: find them via Buffer retainer, free explicitly when done. Check Buffer.allocUnsafe callers — fast, but easy to leak.
// req.app.locals.metrics is global to the app.
app.use((req, res, next) => {
app.locals.metrics.lastByPath ??= {};
app.locals.metrics.lastByPath[req.path] = Date.now(); // grows per unique path
next();
});
Fix: bound the size, or stop using app.locals as a database.
A long-running Promise that captures large request state and never resolves is a slow leak that's hard to spot. Set timeouts on every external call.
node --max-old-space-size=4096 index.js # 4 GB old space (V8's tenured generation)
Useful when you legitimately need more headroom (large in-memory cache, big JSON parsing). Counter-productive when used to mask a leak — you just push the OOM out by a few hours. Always confirm with two-snapshot diff that growth is bounded before raising the ceiling.
clinic doctor index.js — runs your app under load, gives a "is it CPU? memory? event loop? I/O?" verdict before you commit to a deeper tool. (dev.to — Debugging Memory Leaks)clinic heap — focused heap profile.node --inspect-brk=0.0.0.0:9229 index.js + Chrome DevTools — full interactive debugger.--report-on-fatalerror — V8 writes a JSON diagnostic report on OOM crash.Symptom: PM2/Docker scheduled restart at 03:00 daily; nobody knows why. Diagnosis: A leak was masked years ago; nobody ever debugged it. Fix: Take a snapshot diff during the day. Find the actual leak. Restarts hide alerting and cause cold caches.
heap_used without heap_total and rssSymptom: Dashboard shows flat heap_used, alerts never fire, but pods OOM.
Diagnosis: heap_used is what V8 has allocated within the heap. rss includes external buffers and code. heap_total is the cap V8 grew to.
Fix: Track all four (heap_used, heap_total, rss, external) plus arrayBuffers (Node 13+).
Symptom: Snapshot triggers; primary pauses for 8s; uphouse 503s.
Diagnosis: writeHeapSnapshot() is synchronous and walks the entire heap.
Fix: Snapshot from a canary, or drain traffic from one pod, snapshot it, return it to rotation.
Symptom: Diff shows mostly noise (tiny deltas in (string), (closure)).
Diagnosis: Without exercising the suspect path between snapshots, the diff is just GC variance.
Fix: Drive the suspect path with k6/curl/real traffic for several minutes between snapshots.
Symptom: Tracing the leak to a line in node_modules/some-lib/dist/index.cjs:1.
Diagnosis: Bundled / minified code; the position is meaningless.
Fix: --enable-source-maps to get the original source positions; or read the un-bundled package source.
--max-old-space-size set on every Node process automaticallySymptom: Every container has 8 GB allocated, most use 200 MB. Diagnosis: Default applied broadly to silence one rare OOM. Fix: Set per-service based on measured working set + headroom (typically 1.5–2× p99). Different services need different limits.
process.memoryUsage().heapUsed doesn't grow > N MB after running M iterations of the load.heap_used, heap_total, rss, external, arrayBuffers. Panel + alert in grafana-dashboard-builder.node --report-on-fatalerror index.js; reports archived from CI / production.--heapsnapshot-signal=SIGUSR2 AND via authenticated admin endpoint (v8.writeHeapSnapshot).--enable-source-maps) so retainer chains point at real source.--max-old-space-size set deliberately per service, not as a global default.lru-cache (or equivalent) is used for any in-process cache; raw Map/Object caches reviewed for unbounded growth.emitter.on) audited; per-request subscription patterns replaced with once or removed.external memory tracked.node.heap_used_after_ms recorded for drift detection (see opentelemetry-instrumentation).go-pprof-profiling.worker_threads-specific debugging — overlapping but distinct.kubernetes-debugging-runbook for OOM-killed pods.v8.writeHeapSnapshot, --heapsnapshot-signal, two-snapshot Comparison mode). nodejs.org/learn/diagnostics/memory/using-heap-snapshotdata-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.