My Node.js Server Was Leaking Memory in Production. Here's How I Found It.

Our Node.js server was crashing in production. Not once — every single instance was going down, one after another, and Sentry (logging) was flooding with alerts. We had auto-scaling configured with a minimum of 3 instances and a maximum of 10. So users weren't getting hit directly — as one instance crashed, another spun up to replace it. But every new instance had the same code, the same leak. It was a continuous cycle of crash and restart, just slow enough that users didn't feel it. When I finally looked at the error, it was a fatal one:
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
The Node.js process wasn't throwing an exception — it was hitting the V8 heap limit and dying. No graceful shutdown, no catch block. Just a crash. I knew it was a memory leak. What I didn't know was how to debug one. I had never been in this situation before. That incident forced me to actually understand how Node.js manages memory — how V8 allocates the heap, how garbage collection works, and how to track down exactly what is holding memory that should have been released. This is what I learned.
What is a memory leak
A memory leak happens when your application holds references to objects it no longer needs. The garbage collector cannot free them — because from its perspective, they are still in use.
The core insight: Garbage collector is not broken. Your code still holds the reference. That is the entire problem.
The result: heapUsed grows continuously. CPU spikes as GC runs more frequently trying to recover space. Eventually Node.js hits its heap limit — by default somewhere between 1.5 GB and 4 GB depending on the system and Node.js version — and throws :
How Node.js memory is structured
Stack memory
Stores primitive values, function call frames, and short-lived data. Automatically managed — no GC involvement. Very fast.
Heap memory
Stores objects, arrays, closures, and function objects. This is where all long-lived data lives — and where leaks happen.
Inspecting memory usage
Watch heapUsed over time. If it grows across requests and never fully recovers after garbage collection (GC) , you have a leak.
const mem = process.memoryUsage();
console.log({
heapUsed: mem.heapUsed, // memory your app is actively using
heapTotal: mem.heapTotal, // total heap allocated by V8
rss: mem.rss, // total resident set size (OS level)
external: mem.external // C++ objects bound to JS objects
});
Garbage collection — Mark and Sweep
V8 uses a Mark and Sweep algorithm. It begins from a set of GC roots — the global scope, active function call stacks, and live closures — then traces every object reachable from those roots.
Step 1 — Mark
Starting from GC roots, V8 traverses the entire object graph and marks every reachable object as alive.
Step 2 — Sweep
Everything not marked is considered unreachable and is freed. The memory is returned to the heap allocator.
Why leaks still happen
A single reference from a GC root keeps an entire object graph alive. One forgotten closure, one lingering event listener — that is enough to prevent GC from cleaning up everything that object touches.
// GC can clean this — no references remain after reassignment
let user = { name: 'Ali' };
user = null;
// GC cannot clean this — the users array is a GC root reference
// Every request pushes a new object. Nothing removes them.
const users = [];
app.get('/', () => {
users.push({ name: 'Ali' });
});
Common causes
1. Global variables
// Bad — grows for the entire process lifetime
global.cache = [];
app.get('/', () => global.cache.push(req.body));
Objects assigned to the global scope are GC roots. Any unbounded growth is permanent.
2. Event listeners never removed
emitter.on('data', processData);
// Good — remove when no longer needed
emitter.removeListener('data', processData);
// or
emitter.off('data', processData);
Each listener holds a reference to its callback and the closure scope around it.
3. Timers never cleared
const interval = setInterval(() => doSomething(), 1000);
// Good to clear otherwise the interval and its closure are never freed
clearInterval(interval);
A forgotten setInterval keeps its callback — and everything that callback closes over — alive forever.
4. Closures holding large outer-scope data
function outer() {
const hugeData = new Array(1_000_000).fill('x');
// inner holds a reference to the outer scope
// hugeData cannot be freed as long as inner is reachable
return function inner() {
console.log('Hello');
};
}
If inner is stored anywhere reachable (a global, a map, a callback), hugeData stays in memory.
5. Unbounded in-memory cache
// Bad — grows indefinitely
const cache = {};
app.get('/user/:id', async (req, res) => {
cache[req.params.id] = await db.getUser(req.params.id);
res.json(cache[req.params.id]);
});
// Good — use an LRU cache with a size limit
const LRU = require('lru-cache');
const cache = new LRU({ max: 500, ttl: 1000 * 60 * 5 });
6. Unclosed database connections and streams
// Bad — connection never released back to pool
app.get('/data', async (req, res) => {
const conn = await pool.getConnection();
const rows = await conn.query('SELECT * FROM users');
res.json(rows);
// conn.release() missing
});
// Good
app.get('/data', async (req, res) => {
const conn = await pool.getConnection();
try {
const rows = await conn.query('SELECT * FROM users');
res.json(rows);
} finally {
conn.release();
}
});
How to detect and debug
The red flag — exponentially growing heap
Normal healthy pattern: memory rises, GC runs, memory drops back close to baseline.
Leak pattern: the baseline keeps rising with each GC cycle.
Snapshot A: 100 MB ▓░░░░░░░░░
Snapshot B: 200 MB ▓▓░░░░░░░░
Snapshot C: 350 MB ▓▓▓▓░░░░░░
Snapshot D: 500 MB ▓▓▓▓▓▓▓░░░
← GC ran but the floor keeps rising
Method 1 — Heap snapshot comparison (most effective)
This is the primary technique. Take a baseline snapshot, apply sustained load, take a second snapshot, compare.
Start Node with inspector:
node --inspect app.js
Open Chrome DevTools:
chrome://inspect → Open dedicated DevTools for Node
→ Memory tab → Take heap snapshot
What to look for:
| Term | What it means |
|---|---|
| Retained size | How much memory stays alive because this object exists. High retained size = strong leak candidate. |
| Shallow size | Memory the object itself uses, excluding what it references. Less useful for leak detection. |
| Detached objects | Objects that should have been freed but still have at least one live reference. Filter the class list for (Detached). |
Compare two snapshots:
In the Memory tab, use the comparison view. Sort by "Delta" — objects with a large positive delta are growing between snapshots.
| Object | Snapshot A | Snapshot B | Snapshot C |
|---|---|---|---|
users [] |
100 | 10,000 | 50,000 |
cache {} |
200 | 8,400 | 41,200 |
EventEmitter listeners |
4 | 4 | 4 |
The first two rows are clearly leaking. The listener count being stable is a good sign.
Method 2 — heapdump (programmatic snapshots)
Useful when you can't attach a debugger in production.
npm install heapdump
const heapdump = require('heapdump');
// Write a snapshot on demand
heapdump.writeSnapshot('./heap-' + Date.now() + '.heapsnapshot');
// Or trigger via signal
process.on('SIGUSR2', () => {
heapdump.writeSnapshot('./heap-' + Date.now() + '.heapsnapshot');
console.log('Heap snapshot written');
});
Send kill -USR2 <pid> to capture at any point. Open the .heapsnapshot file in Chrome DevTools → Memory → Load.
Method 3 — clinic.js
npm install -g clinic autocannon
clinic doctor -- node app.js
# In a separate terminal, generate load using autocannon
autocannon -c 10 -d 3 http://localhost:3000
Runs your app under a profiler and generates a browser-based report covering CPU, memory pressure, and event loop lag. Can surface memory issues automatically without manual heap snapshot analysis.
Also useful: clinic heapprofiler for allocation-level profiling.
Prevention checklist
Clear all
setIntervalandsetTimeoutwhen no longer neededRemove event listeners with
emitter.off()when the listener is doneNever grow global objects without a corresponding removal path
Use LRU caching with a bounded size — never a plain unbounded object
Close DB connections, file streams, and sockets in
finallyblocksAudit closures that reference large outer-scope objects
Monitor
heapUsedin production — alert if it exceeds a thresholdCompare heap snapshots before and after sustained load, not just at startup
Conclusion
A memory leak is not Node.js forgetting to clean up. It is your application still holding a reference to something it no longer uses. GC is working correctly.
memory leak = unwanted reference staying reachable from a GC root
Find the reference. Remove it. The GC handles the rest.
Further Reading
Code & Walkthroughs
Heap Snapshot Walkthrough — step by step heap snapshot comparison with a real Express app
Express Profiling Demo — full clinic + autocannon setup and results




