Overview
Node processes that grow steadily until the container gets OOM-killed are a specific kind of frustrating, because the service looks healthy right up until it doesn't. The heap grows slowly, restarts fix it temporarily, and nothing in your logs points at a cause.
Here's a process that usually finds it in under an hour.
First, confirm it's actually a leak
Plenty of things look like leaks and aren't. A service that grows from 80 MB to 300 MB over the first ten minutes and then plateaus is just warming up — V8's garbage collector is lazy by design and doesn't return memory to the OS aggressively.
A real leak keeps climbing. Log heap usage on a timer and look at the shape:
setInterval(() => {
const mb = process.memoryUsage();
console.log(JSON.stringify({
rss: Math.round(mb.rss / 1024 / 1024),
heapUsed: Math.round(mb.heapUsed / 1024 / 1024),
heapTotal: Math.round(mb.heapTotal / 1024 / 1024),
external: Math.round(mb.external / 1024 / 1024),
}));
}, 60_000);
The number that matters is heapUsed. If it climbs in a sawtooth pattern that rises higher each cycle and never comes back down to the previous baseline, you have a leak. If it climbs and holds, you probably don't.
external climbing instead is a different problem — that's buffers and typed arrays, usually a stream or a database driver holding onto things.
Get a heap snapshot
Run the service with the inspector enabled:
node --inspect=0.0.0.0:9229 server.js
For a service in Docker, publish port 9229 and connect Chrome to chrome://inspect. Then take two snapshots: one after the service is warm, and one after it has been running long enough for the leak to show.
The comparison view is what you want — snapshots have a "Comparison" mode that shows the delta between two captures. Sort by "Delta" and the leaking object type is usually near the top.
If you can't keep a debugger attached, the heapdump package writes a snapshot to disk on demand:
const heapdump = require('heapdump');
process.on('SIGUSR2', () => {
const file = `/tmp/heap-${Date.now()}.heapsnapshot`;
heapdump.writeSnapshot(file, (err) => {
console.log(err || `Wrote ${file}`);
});
});
Then kill -USR2 <pid> twice, with a gap in between, and load both files into Chrome DevTools → Memory → Load.
The usual suspects
Almost every Node leak I've debugged falls into one of six categories.
| Culprit | What to look for |
|---|---|
| Unbounded cache | A module-level Map or object that only ever gets added to |
| Event listener accumulation | emitter.listenerCount() growing over time |
| Timers never cleared | setInterval in a request handler without a matching clearInterval |
| Closures holding large objects | A callback that captures a request body or a big buffer |
| Global arrays | Metrics, logs, or history accumulated in module scope |
| Unclosed resources | Database clients or streams created per request and never released |
The cache one
This is the most common by a wide margin, and it usually looks like this:
const cache = new Map();
function getUser(id) {
if (cache.has(id)) return cache.get(id);
const user = fetchUserSync(id);
cache.set(id, user); // never evicted
return user;
}
Under a steady stream of distinct IDs, that map grows forever. Swap it for an LRU with a bound:
const LRU = require('lru-cache');
const cache = new LRU({
max: 5000,
ttl: 1000 * 60 * 5,
});
If you genuinely need all entries, that's fine — but put it behind something that persists, not a process that restarts.
The listener one
Attaching a listener inside a function that runs per-request is the classic way to leak. Node prints a warning at eleven listeners, but only for a single event name on a single emitter, so it's easy to miss:
function handleRequest(req, res) {
process.on('SIGTERM', () => {
// a new listener added on every single request
});
}
Find them by dumping listener counts:
const counts = {};
for (const name of process.eventNames()) {
counts[name] = process.listenerCount(name);
}
console.log(counts);
If any number climbs across requests, that's your leak.
Reading a snapshot without drowning
Heap snapshots are enormous and mostly noise. A few filters make them tractable:
- Switch to Comparison view. Absolute sizes tell you what's big; deltas tell you what's growing.
- Filter by
Constructorand look for your own class names. IfObjectis at the top with tens of thousands of new instances, expand it and look at the retained paths. - Ignore internals like
(compiled code),(system), andArrayBufferunlessexternalmemory was the thing climbing. - Click an object and read the Retainers panel at the bottom. That's the chain of references keeping it alive — the actual answer to "why isn't this collected."
Testing a suspected fix
Once you think you've found it, verify rather than assume. A rough load test against the endpoint that triggers the suspect code, with heap logging on, is enough:
for i in $(seq 1 20000); do
curl -s http://localhost:3000/api/endpoint > /dev/null
done
Compare heapUsed before and after. If it returns to roughly the starting point after a forced GC (node --expose-gc and global.gc()), the fix is real.
Preventing the next one
Set a container memory limit and let the orchestrator restart the process when it's exceeded. That's a bandage, not a fix, but it converts "the whole node goes down" into "one pod restarts," which buys you time to find the actual cause. Add the heap logging permanently — it's two lines and it turns a mystery into a graph.
