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.