A Node.js process that slowly eats more RAM until it gets OOM-killed is one of the most frustrating bugs to chase, because the stack trace at crash time tells you nothing about the actual cause. The leak happened minutes or hours earlier. Here’s a reproducible way to find it using tools you already have installed.
Step 1: confirm it’s actually a leak
Memory growth alone isn’t proof — Node’s garbage collector is lazy by design. Watch RSS over time under steady load before assuming you have a leak.
node --expose-gc your-app.js
# in another terminal, sample memory every 5s
watch -n 5 'ps -o rss,vsz,pid,cmd -p $(pgrep -f your-app.js)'
If RSS keeps climbing after several full GC cycles under constant load, it’s a real leak, not just uncollected garbage.
Step 2: start the process with the inspector
node --inspect=0.0.0.0:9229 your-app.js
# or for an app that crashes fast:
node --inspect-brk your-app.js
Open chrome://inspect in Chrome, click “Configure” to add your host:port if it’s remote, then click “inspect” under Remote Target. This gives you the full DevTools Memory panel against a live Node process.
Step 3: take heap snapshots and compare
- In DevTools, go to the Memory tab and select Heap snapshot.
- Take a baseline snapshot right after startup, once the app is warmed up.
- Drive traffic to the endpoint you suspect (repeat the same request 50-100 times).
- Take a second snapshot.
- Switch the snapshot view to Comparison and sort by Delta to see which object types grew and didn’t shrink back.
Look for objects with a growing “# Retained Size” that map to your own code — arrays that keep growing, event listeners that never get removed, or closures capturing large objects.
Step 4: automate snapshot capture in production
You can’t always attach Chrome DevTools to a production server. Use heapdump-style capture triggered by a signal instead:
const v8 = require('node:v8');
const fs = require('node:fs');
process.on('SIGUSR2', () => {
const file = `/tmp/heap-${Date.now()}.heapsnapshot`;
const stream = v8.getHeapSnapshot();
const out = fs.createWriteStream(file);
stream.pipe(out);
console.log('Heap snapshot written to', file);
});
Trigger it with kill -USR2 <pid>, take two snapshots ten minutes apart under load, then pull both files locally and load them into Chrome DevTools’ Memory tab for comparison.
Common culprits worth checking first
- Event listeners added on every request without a matching
removeListener. - Module-level arrays or Maps used as caches with no eviction policy.
- Closures inside
setInterval/setTimeoutcallbacks that capture large request objects and are never cleared. - Unbounded in-memory queues that grow faster than they’re drained.
Useful next reads
If the leak only shows up in production, review your deployment setup in how to deploy a Node.js app with Nginx and PM2 — PM2’s max_memory_restart is a safety net, not a fix.
Quick FAQ
Does –expose-gc fix the leak?
No, it only lets you force garbage collection manually for testing. It’s a diagnostic tool, not a solution.
Can I profile memory without stopping production traffic?
Yes — v8.getHeapSnapshot() triggered by a signal captures a snapshot without pausing the event loop for long, though it does briefly block during capture.
How many heap snapshots do I need to find a leak?
Two is the minimum for a comparison view; three (baseline, mid, late) makes it much easier to confirm growth is linear and ongoing rather than a one-time spike.
Leave a Reply