A memory leak in Node is when our process keeps holding on to memory it doesn’t need anymore. RSS climbs. Eventually we hit the heap limit (default ~1.5 GB on 64-bit) and V8 kills us with JavaScript heap out of memory.
JavaScript has a garbage collector — it frees objects nothing references. So a “leak” really means we’re still referencing the object even though we don’t need it. Find the reference, break it, leak fixed.
The classic causes
1. Closures over big data
function buildHandler(hugeDataset) {
return function (req, res) {
res.json({ count: hugeDataset.length });
};
}
app.get('/count', buildHandler(loadGigabyteFile()));
The handler captures hugeDataset. As long as the handler is registered (forever), the dataset stays in memory. Even if we only ever read .length from it.
Fix: don’t close over data we don’t need.
const count = loadGigabyteFile().length; // extract what we need
app.get('/count', (req, res) => res.json({ count }));
// hugeDataset can be GC'd now
2. EventEmitter listener leaks
Every .on() adds a listener. If we add listeners in a request handler without removing them, they pile up forever.
// BAD — adds a listener per request
app.get('/stream', (req, res) => {
someEmitter.on('data', (chunk) => res.write(chunk));
});
Node warns us once we cross 10 listeners on the same event:
(node:1234) MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 data listeners added to [EventEmitter].
Fixes:
- Use
.once()if we only need it once. - Remove the listener when we’re done:
emitter.off('data', fn). - For per-request listeners, attach to a per-request object (the response stream), not a shared global emitter.
3. Unbounded global caches
const cache = new Map();
app.get('/user/:id', async (req, res) => {
if (!cache.has(req.params.id)) {
cache.set(req.params.id, await db.getUser(req.params.id));
}
res.json(cache.get(req.params.id));
});
Looks innocent. After a million unique user IDs, our Map has a million entries. Forever.
Fix: bounded cache with TTL/LRU.
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 10_000, ttl: 1000 * 60 * 5 });
4. Timers that capture context
function handleConnection(conn) {
setInterval(() => conn.ping(), 30_000);
}
If conn disconnects but we never clearInterval, the timer keeps conn alive. Always store the timer ID and clear it on cleanup.
5. Global arrays we push to and never drain
const recentRequests = [];
app.use((req, res, next) => {
recentRequests.push({ url: req.url, time: Date.now() });
next();
});
Grows forever. Use a ring buffer, or push to a real log system.
Spotting a leak
The telltale sign: RSS climbs steadily under steady load and never comes back down. A healthy process has memory that goes up during traffic, then GC reclaims it during quiet periods, oscillating in a band. A leaking process trends up monotonically.
// Cheap monitoring
setInterval(() => {
const m = process.memoryUsage();
console.log({
rss: (m.rss / 1024 / 1024).toFixed(1) + 'MB',
heapUsed: (m.heapUsed / 1024 / 1024).toFixed(1) + 'MB',
});
}, 10_000);
Force GC for cleaner snapshots
V8 might be holding objects that are technically collectable. Force a GC right before snapshotting:
node --expose-gc server.js
if (global.gc) global.gc();
// now take snapshot
Otherwise we end up chasing “leaks” that are really just GC laziness.
When it’s not actually a leak
- First few minutes of high traffic — V8’s heap grows up to its working set. Normal.
heapTotalgrows butheapUsedstays flat — heap fragmentation, not a leak.- Native memory growth — RSS grows but heap doesn’t. Could be a native addon (sharp, bcrypt, gRPC) leaking C++ memory. Way harder to debug.
The boring fix nobody talks about
If we can’t find the leak in a hurry and the process is going to OOM in 12 hours, restart it on a schedule. PM2’s max_memory_restart or Kubernetes’ liveness probe + memory limit will recycle the process before it dies. Not glamorous but buys us time to actually fix it.