Skip to main content
Back to articles
intermediateNode.jsdebuggingmemoryperformance

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.

·3 min read

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:

middleware/metrics.js
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:

middleware/metrics.js
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.