The Memory Leak Detective: Debugging Long-Running Node.js Services
The worst kind of bug is the one that doesn't exist on Monday and owns your pager by Friday. Memory leaks in Node.js services are exactly that: the process starts at a healthy 120 MB, climbs 2 MB an hour, and three weeks later the OOM killer has a new hobby. This is a field guide to finding those leaks — not with vibes, but with heap snapshots, diffing, and an understanding of the three traps that cause most of them.
The shape of a leak
A leak is not "memory goes up." Memory goes up in every healthy process: the garbage collector runs when it needs to, not when you want it to. What distinguishes a leak is unbounded growth that survives GC cycles. Before you debug anything, watch the process long enough to see the sawtooth:
# Sample RSS every 5s; healthy = sawtooth that returns to a floorwhile true; do ps -o rss= -p $(pgrep -f "node dist/server.js") >> rss.log sleep 5done# Then: awk '{print $1/1024}' rss.log | sort -n | tail -1If the floor itself keeps rising across GC cycles, you have a leak. If it rises and falls back to the same floor, you have a busy service and a normal one. Most "leaks" people report are actually the latter — which is why step zero is always measurement, never guessing.
Trap one: the EventEmitter that never forgets
The most common leak in Node is also the most boring: a listener registered once per request, on a long-lived emitter, that is never removed. Every request adds a closure to the emitter's listener array; the emitter keeps the closure alive; the closure keeps its whole scope alive — including the request object, its buffers, and anything else the handler touched.
// The leak. Every request adds a listener that lives forever.const bus = require('./eventBus'); // module-level, long-lived
app.get('/jobs/:id', (req, res) => { bus.on('job:finished', (job) => { res.write(`job ${job.id} done\n`); // res captured forever res.end(); }); bus.emit('job:started', req.params.id);});The fix is almost always once semantics — or better, never registering per-request listeners at all:
// The fix: register once, dispatch to the current request via a token,// or use once() + removeListener in a finally block.app.get('/jobs/:id', (req, res) => { const onDone = (job) => { if (job.id !== req.params.id) return; // not our job res.write(`job ${job.id} done\n`); res.end(); }; bus.on('job:finished', onDone); res.on('close', () => bus.removeListener('job:finished', onDone)); bus.emit('job:started', req.params.id);});Node has a built-in canary for this exact bug: process.on('warning') fires MaxListenersExceededWarning when an emitter passes 10 listeners. If you see that warning in logs, stop everything — it is the leak announcing itself. Listeners are the number-one cause, and the fix is architectural, not cosmetic: long-lived emitters should broadcast events with identifiers, and short-lived handlers should subscribe with a token filter, exactly as above.
Trap two: the closure that captured the world
Closures are the second trap. A closure doesn't capture what you use — it captures the entire lexical environment of the function, and V8 keeps alive anything that could be referenced, even if your code never does. The classic pattern is a memoization cache keyed by object:
// The leak: the Map holds every seen object forever — unbounded cache.const seen = new Map();
function process(item) { if (seen.has(item)) return seen.get(item); // item is the key AND value const result = expensiveWork(item); // whole item graph retained seen.set(item, result); return result;}Every item you ever processed stays in that Map for the life of the process, and because it's both key and value, the entire item graph stays too. The fix is a bounded cache — or no cache:
// The fix: bound the cache, and evict old entries by recency.const MAX = 10_000;const seen = new Map(); // item.id -> result — keys are strings, not graphs
function process(item) { if (seen.has(item.id)) return seen.get(item.id); if (seen.size >= MAX) { const oldest = seen.keys().next().value; // FIFO eviction seen.delete(oldest); } const result = expensiveWork(item); seen.set(item.id, result); return result;}A good rule of thumb: any cache that grows with input size is a leak with extra steps. Caches need three things — a key that isn't a heavyweight object, a size bound, and an eviction policy. If a Map or Set in your codebase lacks any of those, it is a leak candidate.
Trap three: streams and buffers that never drain
The third trap is less famous and more insidious: a Readable that is paused, or a Writable that is backpressured and nobody listens to drain. A paused stream buffers internally, and internal buffers can grow to unbounded size while looking innocent — readable.readableLength never appears in heap snapshots in a way that screams "leak."
// The leak-ish pattern: piping from a fast source to a slow sink with no// backpressure handling — the readable side buffers everything.const fast = getHugeReadable();const slow = getSlowWritable();fast.on('data', (chunk) => slow.write(chunk)); // ignores backpressureThe fix is to let the stream machinery handle pressure — that is literally what pipe and pipeline are for:
// The fix: pipeline() manages backpressure and propagates errors.const { pipeline } = require('node:stream/promises');
await pipeline(getHugeReadable(), getSlowWritable());How to actually find one: snapshots, not guesses
Tools beat intuition. The workflow that finds every leak I've met:
- Take a baseline heap snapshot. Run the service with
node --inspect(or--inspect=0.0.0.0:9229in Docker), openchrome://inspect, and capture a snapshot at a quiet moment. - Generate load.
autocannon -c 50 -d 120 http://localhost:3000/or your load script — exercise the exact path you suspect. - Take a second snapshot. Wait for a GC (
--expose-gc+ a/gcendpoint, ornode --expose-gc -ein tests), then capture again. - Diff. In DevTools, select the newer snapshot and use "Comparison" view. Sort by Delta — the objects whose count grew monotonically with requests are your leak. A growing count of
Closure,Listener,(system)/Mapentries is a fingerprint of one of the three traps above. - Confirm the growth is monotonic, not just high-water-mark noise. Run the load in two bursts with a GC between; if the retained set returns to the same floor after each burst, you're fine.
Snapshot diffing is the single highest-leverage debugging skill for long-running Node services. It turns "memory goes up" into "here is the exact class of object, allocated by this exact stack trace, 40,000 times."
A prevention checklist
- Register
process.on('warning')and treatMaxListenersExceededWarningas a bug, not a log line. - Every
Map/Set/array-cache has a bound and an eviction policy. - Every per-request listener is paired with a
removeListener(or the emitter broadcasts with a filterable id). - Streams use
pipeline()or explicitdrainhandling — never fire-and- forgetwrite(). - CI runs a soak test: 10 minutes of load, snapshot diff, assert the retained set is flat.
The humbling truth is that memory leaks are never exotic. They are mundane lifecycle mistakes — a listener, a cache, a stream — multiplied by traffic. Find the one pattern, fix the pattern, and the whole class of bug disappears with it.