Memory Leaks in Node.js: Detection and Prevention
Closures, event listeners, and uncleared timers — the most common memory leak patterns in Node.js and how to find them before they crash production.

Memory leaks in Node.js don't crash immediately. They're slow — memory usage climbs over hours or days until the process runs out of heap space and dies with a cryptic OOM error. The cause is usually mundane: an event listener that's never removed, a closure that holds a reference longer than expected, or a cache that grows without bounds.
How Node.js Memory Works
V8 (Node's JavaScript engine) uses a generational garbage collector. Objects in the "new space" are collected quickly. Objects that survive multiple GC cycles are promoted to "old space," which is collected less frequently and more expensively.
A memory leak occurs when objects in old space are no longer needed but still referenced — the garbage collector can't reclaim them.
// ❌ Global array that grows without bounds
const requestLog: object[] = [];
app.use((req, res, next) => {
requestLog.push({
url: req.url,
headers: req.headers,
timestamp: Date.now(),
body: req.body, // Large objects accumulate
});
next();
});
// After 1M requests: requestLog holds gigabytes of dataThe Five Common Leak Patterns
1. Event listeners that are never removed
// ❌ Adding a listener on every request — never removed
app.get("/api/stream", (req, res) => {
const handler = (data: Buffer) => {
res.write(data);
};
dataSource.on("data", handler);
// If the client disconnects, the handler is never removed
// The closure holds a reference to `res`, preventing GC
});
// ✅ Clean up on close
app.get("/api/stream", (req, res) => {
const handler = (data: Buffer) => {
res.write(data);
};
dataSource.on("data", handler);
req.on("close", () => {
dataSource.removeListener("data", handler);
});
});2. Closures holding large references
// ❌ Closure captures the entire large object
function processLargeDataset(data: Buffer) {
const processed = expensiveTransform(data);
return function getResult() {
// This closure holds a reference to `data` (the entire Buffer)
// even though it only needs `processed`
return processed;
};
}
// ✅ Let the large reference go out of scope
function processLargeDataset(data: Buffer) {
const processed = expensiveTransform(data);
// `data` reference is not captured by the returned function
return function getResult() {
return processed;
};
}3. Unbounded caches
// ❌ Map that only grows — no eviction policy
const cache = new Map<string, unknown>();
function getCached(key: string, fetcher: () => Promise<unknown>) {
if (cache.has(key)) return cache.get(key);
const value = fetcher();
cache.set(key, value);
return value;
}
// ✅ LRU cache with a maximum size
import { LRUCache } from "lru-cache";
const cache = new LRUCache<string, unknown>({
max: 500, // Maximum 500 entries
ttl: 1000 * 60 * 5, // 5-minute TTL
});4. Timers and intervals that are never cleared
// ❌ setInterval inside a handler — never cleared
class DataPoller {
start() {
setInterval(async () => {
const data = await fetchLatestData();
this.processData(data);
}, 5000);
}
// If this object is "discarded" but the interval still runs,
// it prevents the entire object from being garbage collected
}
// ✅ Store interval ID and clear on cleanup
class DataPoller {
private intervalId: NodeJS.Timeout | null = null;
start() {
this.intervalId = setInterval(async () => {
const data = await fetchLatestData();
this.processData(data);
}, 5000);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
}5. Promises that never resolve
// ❌ Promises that hang forever hold their closure in memory
function waitForEvent(emitter: EventEmitter, event: string) {
return new Promise((resolve) => {
emitter.once(event, resolve);
// If the event never fires, this Promise (and its closure) lives forever
});
}
// ✅ Add a timeout
function waitForEvent(emitter: EventEmitter, event: string, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
emitter.removeListener(event, onEvent);
reject(new Error(`Timeout waiting for ${event}`));
}, timeoutMs);
function onEvent(data: unknown) {
clearTimeout(timer);
resolve(data);
}
emitter.once(event, onEvent);
});
}Detection: Heap Snapshots
V8's built-in inspector lets you take heap snapshots and compare them over time.
// Expose a diagnostic endpoint (behind auth!)
import v8 from "v8";
import fs from "fs";
app.get("/debug/heap-snapshot", requireInternalAuth, (req, res) => {
const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
const snapshotStream = v8.writeHeapSnapshot(filename);
res.json({ file: snapshotStream, message: "Heap snapshot written" });
});# Take two snapshots 10 minutes apart
# Open both in Chrome DevTools (Memory tab)
# Compare: objects that exist in snapshot 2 but not snapshot 1 are potential leaksMonitoring Heap Usage
Track heap usage over time. A healthy process has a sawtooth pattern (memory rises, GC runs, memory drops). A leak shows steadily climbing baseline.
// Log memory metrics every 30 seconds
setInterval(() => {
const usage = process.memoryUsage();
console.log({
heapUsed: Math.round(usage.heapUsed / 1024 / 1024),
heapTotal: Math.round(usage.heapTotal / 1024 / 1024),
rss: Math.round(usage.rss / 1024 / 1024),
external: Math.round(usage.external / 1024 / 1024),
});
}, 30000);Key Takeaways
- Event listeners are the #1 leak source — always remove them when the consumer disconnects
- Closures capture more than you think — be aware of what references are held
- Caches need eviction policies — use LRU with max size and TTL, never unbounded Maps
- Clear all timers — store interval/timeout IDs and clear them on cleanup
- Heap snapshots are your diagnostic tool — compare two snapshots to find retained objects
- Monitor heap usage trends — a rising baseline between GC cycles signals a leak


