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.
Recipe
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:
- RSS grows linearly over days while traffic is flat
- k8s restarts pods with
OOMKilled - GC pause times increase in metrics
- After adding in-memory cache, event bus, or websocket fanout
Working Example
// 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:
- Unbounded
Mapis a classic leak in Node APIs - LRU + TTL bounds memory for upload buffers
- Periodic
memoryUsagelogging helps correlate growth to deploys - DevTools comparison finds objects that grew between snapshots
Deep Dive
How It Works
- V8 allocates on the heap; RSS includes heap + native buffers (BoringSSL, zlib)
- Retaining path shows what reference keeps an object alive
- Detached DOM is a browser problem; in Node look for Timers, EventEmitter listeners, closures over large buffers
- Native leaks (C++ addons) show rising external in
memoryUsage
Common Leak Sites in Node APIs
| 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
clinic heapprofiler --on-port 'autocannon -d 60 localhost:3000' -- node dist/main.js- Runs load while recording allocations
- Flamegraph-style view of hot allocation stacks
- Best when leak appears only under traffic
TypeScript Notes
// WeakMap for metadata tied to object lifetime - entries GC when key is GC'd
const meta = new WeakMap<object, { createdAt: number }>();Gotchas
- Single snapshot conclusion - Heap looks "big" normally. Fix: Compare two snapshots after load.
- Debugging production with --inspect open - Security risk. Fix: Snapshot in staging or use
SIGUSR2heap dump to S3 in controlled runbook. - Blaming V8 for app cache - "Node leaks" often app
Map. Fix: Audit caches after every feature. - Not calling
unref()on debug timers - Timer keeps event loop alive in tests. Fix:.unref()on diagnostic intervals. - Huge snapshots freeze laptop - 2GB heap snapshots are slow. Fix: Reproduce with smaller load in staging first.
Alternatives
| 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 |
FAQs
rss vs heapUsed?
heapUsed is V8 JavaScript objects. rss is total process memory including native buffers. Track both.
How long between snapshots?
5-15 minutes under steady autocannon or replayed production traffic.
Do arrow functions leak?
They leak if they close over large objects and are stored globally. The function type is not the issue - retention is.
Socket.io leaks?
Orphaned namespaces and per-socket listeners accumulate. Remove listeners on disconnect.
Prisma memory?
Query engine uses native memory. Rising external with flat heap may be connection pool or engine - check pool size.
Worker threads heap?
Each worker has separate heap - aggregate RSS across workers in k8s limits.
Can I automate snapshot in CI?
Regression test: run load script, assert heapUsed delta under threshold - flaky but catches major regressions.
clinic doctor vs heapprofiler?
doctor is general event-loop + CPU; heapprofiler focuses allocations for leak hunts.
After fix verification?
Same load test, compare snapshot delta - should flatten over 30+ minutes.
Buffer pool leaks?
Retained Buffer from pooled parsers show in snapshot as (string) or Buffer - check HTTP middleware retaining bodies.
Related
- Debugging Basics -
--inspectsetup - Event-Loop Stall Incident - CPU vs memory
- Connection Pool Exhaustion - pool not released
- OOM Killer Response - production incident
- Graceful Shutdown - release resources
Stack 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.