Skip to content
Back to Quality Assurance

Memory Leak Detection

Find and fix memory leaks where the application's memory grows unbounded over time, eventually crashing.

Why

A memory leak is a slow killer — the app runs fine for hours or days, then suddenly crashes with an OOM (Out of Memory) error, and the restart loses in-flight requests and user sessions. In a long-running server or SPA, every leaked reference (an event listener never removed, a closure capturing a growing array, a cache with no eviction) accumulates until the process exhausts its heap. The worst part is that the leak is invisible in normal testing — you need to run for hours under load to see the growth, and by then the root cause is buried in millions of allocations. Detecting leaks early, with heap snapshots and growth monitoring, prevents the 3am crash and the user-visible outage that follows.

When

When a long-running process (server, worker, SPA) shows growing memory in monitoring. Also as a pre-launch check for any service that runs continuously. Test for leaks after adding caches, event listeners, or subscription patterns.

How

1. Confirm the leak with monitoring
   - Track process memory (RSS, heap used) over time in APM
   - A leak shows steady growth that never plateaus, even at idle
   - Rule out normal growth (cache warmup, JIT compilation) — does it plateau after 30 min?

2. Reproduce the leak in a controlled environment
   - Run the suspected operation in a loop (e.g., create and destroy 10000 requests)
   - Force garbage collection between iterations (node --expose-gc, global.gc())
   - If memory grows despite GC, there is a leak — retained references prevent collection

3. Take heap snapshots and compare them
   - Node: use the inspector — node --inspect, then Chrome DevTools → Memory → Heap snapshot
   - Take snapshot 1, run the operation 1000 times, force GC, take snapshot 2
   - Compare snapshot 2 to snapshot 1 — the "delta" shows what was allocated and not freed
   - Look for objects with high retained size: arrays, closures, DOM nodes, Map/Set

4. Find the retaining path
   - In the heap snapshot, find the leaked object and look at its "retainers"
   - The retainer is the reference that prevents GC — an event listener, a cache, a closure
   - Common patterns: addEventListener without removeEventListener, setInterval without clearInterval,
     a Map that grows without deletion, a closure that captures a growing array

5. Fix the leak and verify
   - Remove the listener, clear the interval, add eviction to the cache, weaken the reference
   - Re-run the loop test — memory should plateau, not grow
   - Deploy and watch the monitoring — the growth curve should flatten

Example

// Leak: event listener never removed
class EventEmitter {
  constructor() {
    this.handlers = [];
  }
  on(handler) {
    this.handlers.push(handler);
  }
  off(handler) {
    // BUG: never called, so handlers grow forever
    this.handlers = this.handlers.filter(h => h !== handler);
  }
}

// Detect: run in a loop, force GC, measure heap
// leak-test.js
const emitter = new EventEmitter();
function runOnce() {
  const data = new Array(10000).fill("x"); // 80KB per iteration
  const handler = () => console.log(data.length);
  emitter.on(handler);
  emitter.emit("event");
  emitter.off(handler); // if off is broken or forgotten, data is retained
}

for (let i = 0; i < 10000; i++) {
  runOnce();
  if (i % 1000 === 0) {
    global.gc(); // force GC (--expose-gc)
    const used = process.memoryUsage().heapUsed;
    console.log(`Iteration ${i}: ${(used / 1024 / 1024).toFixed(1)} MB`);
  }
}
// Leaking output:  10 → 50 → 110 → 170 MB (grows linearly)
// Fixed output:    10 → 12 → 12 → 12 MB (plateaus)

// Fix: ensure off() is always called, or use WeakRef / FinalizationRegistry
// Or use a Set and delete on off, or use AbortController for listener lifecycle
const controller = new AbortController();
emitter.on(handler, { signal: controller.signal });
// When done:
controller.abort(); // automatically removes the listener, releases the closure

Common Problems

  • ⚠Adding event listeners in a loop or on every render without removing them — each iteration adds a new listener that captures its closure, and the memory grows with every request or render cycle.
  • ⚠Using a Map or object as a cache without any eviction — the cache grows with every unique key, and after a week of production traffic it holds millions of entries and exhausts the heap.
  • ⚠Closures that capture large objects unintentionally — a callback closes over a 10MB request body even though it only needs the response status, and the body is retained until the callback is GC'd.
  • ⚠setInterval or setTimeout callbacks that reference large objects and are never cleared — the timer keeps the closure alive, which keeps the large object alive, long after it is needed.

Best Practices

  • ✓Always pair addEventListener with removeEventListener, or use AbortController to manage listener lifecycle — the controller.abort() automatically removes all listeners registered with its signal.
  • ✓Use an LRU cache (lru-cache npm package) with a max size instead of a plain Map — the cache evicts the oldest entries when full, bounding memory regardless of traffic.
  • ✓Take heap snapshots before and after a load test and compare the delta — the comparison immediately shows what was allocated and not freed, pinpointing the leak in minutes.
  • ✓Monitor process memory in production and alert on sustained growth — a memory curve that never plateaus is a leak, and catching it at 10% growth is far easier than at 90% OOM crash.