How I Fixed a Node.js Memory Leak That Had Stumped a Team for Weeks
A walkthrough of diagnosing and eliminating a heap leak in a production Express API — from first heap snapshot to the final one-line fix.
A client reached out after their production Node.js API had been silently leaking memory for three weeks. The process would start at ~300 MB and steadily climb to 2 GB before auto-restarting. Their team had bumped --max-old-space-size=4096 as a quick "fix". It wasn't.
Here is the full diagnostic trail and the final resolution.
📈 The Symptoms
heap_used: 312 MB (T+0h)
heap_used: 580 MB (T+1h)
heap_used: 940 MB (T+2h)
heap_used: 1.6 GB (T+3h)This exhibited a classic slow leak pattern — steady, linear accumulation over time rather than a sudden spike.
Note
Slow leaks are often more dangerous than fast ones. A fast leak crashes quickly and forces an immediate fix. A slow leak quietly degrades performance for days before causing subtle failures.
🔍 Step 1 — Capture Heap Snapshots (Not Just Metrics)
Monitoring process.memoryUsage().heapUsed indicates that a leak exists, but not where or why.
Using node --inspect and Chrome DevTools, I captured three snapshots spaced 30 minutes apart:
| Snapshot | Heap Size | Retained Objects |
|---|---|---|
| Snapshot 1 | 320 MB | 1.2M |
| Snapshot 2 | 490 MB | 1.8M |
| Snapshot 3 | 680 MB | 2.5M |
Filtering by "Objects allocated between Snapshot 1 and 2" immediately revealed the root culprit: ~600,000 retained EventEmitter instances.
🕵️ Step 2 — Trace the Allocation Source
Inspecting the allocation trace highlighted this metrics middleware:
app.use((req, res, next) => {
const emitter = new EventEmitter(); // ← Allocated on every request
emitter.on('finish', () => {
recordMetric(req.path, res.statusCode);
});
res.on('finish', () => emitter.emit('finish'));
next();
});The Flaw:
res.on('finish', ...) attached a listener to the long-lived response object. However, emitter itself was never detached or cleaned up. The response held a reference to the handler closure, and that closure retained the entire emitter instance.
Caution
EventEmitter instances cannot be garbage-collected if they remain referenced anywhere in an active closure chain. Creating one per request without cleanup will rapidly leak heap memory.
💡 Step 3 — The One-Line Fix
Eliminating the unnecessary intermediate emitter resolved the leak entirely:
app.use((req, res, next) => {
res.on('finish', () => {
// Direct execution — no intermediate EventEmitter needed
recordMetric(req.path, res.statusCode);
});
next();
});✅ Post-Fix Verification
After deploying the updated code:
heap_used: 295 MB (T+0h)
heap_used: 298 MB (T+1h)
heap_used: 301 MB (T+2h)
heap_used: 304 MB (T+3h)The heap plateaued cleanly. The minor ~3 MB/hour variance reflects standard V8 GC fluctuations under normal load.
📋 Memory Leak Investigation Checklist
- Capture Multiple Snapshots: Take at least 3 snapshots over time (never rely on a single snapshot).
- Isolate Delta Allocations: Filter DevTools by objects allocated between snapshots.
- Audit Short-Lived Objects: Look for accumulating constructors (
EventEmitter,Promise,ServerResponse). - Follow Retainer Chains: Root out which parent reference keeps the closure alive.
- Confirm Flat Heap: Verify memory stability post-deployment.
If your team is stuck on a similar issue, describe it here to get a diagnosis and resolution plan.