Memory Leak Hunt
Node.js APIs that never release memory eventually OOM-kill pods. Hunt leaks with heap snapshots in Chrome DevTools, clinic heapprofiler under load, and code review of listeners, caches, and closures.
Search across all documentation pages
Node.js APIs that never release memory eventually OOM-kill pods. Hunt leaks with heap snapshots in Chrome DevTools, clinic heapprofiler under load, and code review of listeners, caches, and closures.
Quick-reference recipe card - copy-paste ready.
# Start with inspect for snapshots
node --inspect src/main.ts
# Or Clinic heap profiler
npm install -g clinic
clinic heapprofiler -- node dist/main.js
# exercise API, stop process, open generated HTML# Signal heap snapshot to disk (Node 24)
kill -USR2 <pid> # if --heapsnapshot-near-heap-limit configured
node --heapsnapshot-signal=SIGUSR2 dist/main.jsWhen to reach for this:
OOMKilled// LEAK: unbounded cache keyed by request - anti-pattern for demo
const leakCache = new Map<string, Buffer>();
import express from "express";
const app = express();
app.post("/upload", express.raw({ type: "*/*", limit: "5mb" }), (req, res) => {
const id = crypto.randomUUID();
leakCache.set(id, Buffer.from(req.body)); // never evicted
res.json({ id });
});
// FIX: LRU with max entries + TTL
import { LRUCache } from "lru-cache";
const cache = new LRUCache<string, Buffer>({
max: 500,
ttl: 1000 * 60 * 15,
});
app.post("/upload-fixed", express.raw({ type: "*/*", limit: "5mb" }), (req, res) => {
const id = crypto.randomUUID();
cache.set(id, Buffer.from(req.body));
res.json({ id });
});
// DEBUG: log heap periodically in staging
setInterval(() => {
const m = process.memoryUsage();
console.log(JSON.stringify({ rss: m.rss, heapUsed: m.heapUsed, external: m.external }));
}, 60_000).unref();Heap snapshot workflow:
1. node --inspect dist/main.js
2. Chrome DevTools -> Memory -> Take heap snapshot (baseline)
3. Run load test 5-10 minutes
4. Take second snapshot
5. Comparison view -> sort by "Size Delta" -> inspect retaining pathsWhat this demonstrates:
Map is a classic leak in Node APIsmemoryUsage logging helps correlate growth to deploysmemoryUsage| Site | Symptom | Fix |
|---|---|---|
EventEmitter.on without off | Listener count grows | once or remove on shutdown |
Global Map cache | Heap grows with unique keys | LRU, Redis, TTL |
setInterval not cleared | Process never releases handles | clearInterval on shutdown |
Per-request closure holding req | Whole body retained | Narrow closure scope |
| Open DB connections | External memory + pool errors | pool.end() on SIGTERM |
clinic heapprofiler --on-port 'autocannon -d 60 localhost:3000' -- node dist/main.js// WeakMap for metadata tied to object lifetime - entries GC when key is GC'd
const meta = new WeakMap<object, { createdAt: number }>();SIGUSR2 heap dump to S3 in controlled runbook.Map. Fix: Audit caches after every feature.unref() on debug timers - Timer keeps event loop alive in tests. Fix: .unref() on diagnostic intervals.| Alternative | Use When | Don't Use When |
|---|---|---|
node --trace-gc | Suspect GC tuning issue | Need object retainers |
| APM heap (Datadog, New Relic) | Continuous prod monitoring | Deep retainers path |
memwatch-next | Legacy projects | Prefer built-in snapshot tools |
| Restart pods daily | Emergency only | Root cause unfixed |
heapUsed is V8 JavaScript objects. rss is total process memory including native buffers. Track both.
5-15 minutes under steady autocannon or replayed production traffic.
They leak if they close over large objects and are stored globally. The function type is not the issue - retention is.
Orphaned namespaces and per-socket listeners accumulate. Remove listeners on disconnect.
Query engine uses native memory. Rising external with flat heap may be connection pool or engine - check pool size.
Each worker has separate heap - aggregate RSS across workers in k8s limits.
Regression test: run load script, assert heapUsed delta under threshold - flaky but catches major regressions.
doctor is general event-loop + CPU; heapprofiler focuses allocations for leak hunts.
Same load test, compare snapshot delta - should flatten over 30+ minutes.
Retained Buffer from pooled parsers show in snapshot as (string) or Buffer - check HTTP middleware retaining bodies.
--inspect setupStack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 16, 2026