Skip to content
Back to Build Tutorials

Memory Leak

Detect and fix memory leaks with heap snapshots and retention-path analysis before they OOM in prod.

Why

A memory leak in a long-running server is a ticking bomb: it works fine in dev (you restart every 5 min) and passes every test, then OOMs in prod at 4am with no clue why. The leak is almost always a retained reference — an event listener you forgot to remove, a closure capturing a big object, a Map that grows unbounded — and the fix is one line once you find it. The detection method is: take two heap snapshots, diff them, and inspect the retained objects. Chrome DevTools and Node's --inspect do this in 5 minutes; without it, you're guessing from 'process.memoryUsage() went up'. Leaks that survive a deploy are the worst kind of bug — they're invisible until they crash, and the crash has no stack trace.

When

When memory grows monotonically over time in a long-running process. Takes 1-2 h to find and fix. Skip for CLI tools that exit.

How

1. Confirm it's a leak — memory grows and never drops even after GC
   - Log process.memoryUsage().heapUsed every 60s; if it trends up over 30 min, it's a leak
   - Force GC with --expose-gc + global.gc() to rule out "just hasn't collected yet"

2. Take two heap snapshots 5-10 min apart under load
   - Node: inspector.open(); then chrome://inspect → Memory → Take heap snapshot
   - Or programmatically: v8.writeHeapSnapshot()

3. Diff the snapshots and sort by "delta retained size"
   - The objects that grew between snapshots are the leak
   - Look at the retention path: who is holding the reference?

4. Common leak patterns — check each against your diff
   - Event listener never removed (emitter.on without an emitter.off)
   - Map/Set growing unbounded (cache with no eviction)
   - Closure capturing a large object inadvertently
   - Timer (setInterval) never cleared

5. Fix the retention, verify with a 1-hour soak test
   - Memory should plateau, not grow; re-snapshot to confirm the diff is now empty

Example

// Confirm the leak — log heap over time
setInterval(() => {
  const m = process.memoryUsage();
  console.log(JSON.stringify({ t: Date.now(), heap: m.heapUsed, rss: m.rss }));
}, 60_000);
// if heap trends up over 30 min → leak

// Take a heap snapshot programmatically
import { writeHeapSnapshot } from "node:v8";
app.post("/debug/snapshot", (req, res) => {
  const file = writeHeapSnapshot();
  res.json({ file });  // download and diff in chrome://inspect
});

// Common leak: listener never removed
function onConnect(socket) {
  emitter.on("data", (d) => socket.write(d));   // ← leak: socket closes but listener stays
  socket.on("close", () => emitter.off("data", ???));  // need the same fn reference!
}
// Fix: name the handler so off() can remove it
function onConnect(socket) {
  const onData = (d) => socket.write(d);
  emitter.on("data", onData);
  socket.on("close", () => emitter.off("data", onData));
}

Common Problems

  • ⚠Blaming the leak on the framework and not investigating — 9 times out of 10 it's your code holding a reference.
  • ⚠Taking one snapshot and guessing; you need two snapshots and a diff to see what's growing.
  • ⚠Fixing the leak but not adding a soak test, so it regresses in the next refactor.
  • ⚠Using a weak map where a regular map is needed (or vice versa) — the fix creates a new bug.

Best Practices

  • ✓Run a 1-hour soak test in CI on every server PR — memory should plateau; a monotonic rise fails the build.
  • ✓Use WeakMap/WeakRef for caches keyed by objects that should be GC-able — the cache evicts itself when the key dies.
  • ✓Cap every in-memory cache with an LRU eviction (e.g. lru-cache) so a runaway can't OOM — bounded by construction.
  • ✓Set a memory limit (Node --max-old-space-size) below the container limit so you get a useful OOM crash, not a kernel kill.