A memory leak in a Node.js application rarely announces itself. Response times stay normal for hours, then suddenly your process is consuming gigabytes of RAM, garbage collection pauses start stalling the event loop, and the container gets OOM-killed in production. Because Node.js processes are typically long-running servers rather than short-lived scripts, small leaks that would be harmless in a CLI tool compound into real outages. This guide walks through how V8 manages memory, the leak patterns that show up most often in real Node.js codebases, and the tools you can use today to find and fix them.
How Memory Works in Node.js
Node.js uses V8’s heap to store JavaScript objects, and V8’s garbage collector (GC) reclaims memory for objects that are no longer reachable from any root reference (global objects, the current call stack, or active closures). A memory leak, in this context, isn’t really Node.js failing to free memory — it’s your code accidentally keeping a reference alive longer than intended, so the GC correctly refuses to collect it.
You can check current memory usage from inside a running process:
const used = process.memoryUsage();
console.log({
rss: `${Math.round(used.rss / 1024 / 1024)} MB`, // total process memory
heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`,
heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`,
external: `${Math.round(used.external / 1024 / 1024)} MB`, // C++ objects bound to JS
});
A steadily climbing heapUsed across repeated, similar requests — one that never drops back down after garbage collection — is the clearest early signal of a leak.
The Leaks That Show Up Most Often
1. Event listeners that are never removed
Every time you call .on() without a matching .off() or .removeListener(), you add a new reference from the emitter to your handler’s closure. If the emitter is long-lived (an HTTP server, a database connection pool, a WebSocket server) and listeners are attached per-request, this grows without bound.
// Leaks: a new listener is added on every request, none are ever removed
app.get('/subscribe', (req, res) => {
eventBus.on('update', (data) => res.write(data));
});
// Fixed: remove the listener when the request ends
app.get('/subscribe', (req, res) => {
const handler = (data) => res.write(data);
eventBus.on('update', handler);
req.on('close', () => eventBus.off('update', handler));
});
2. Timers and intervals that outlive their purpose
setInterval callbacks keep their entire closure alive for as long as the interval runs. If the interval is created inside a function that’s called repeatedly (e.g., once per incoming connection) and never cleared, you accumulate one running interval — and its captured variables — per connection.
function startHeartbeat(connection) {
const timer = setInterval(() => connection.send('ping'), 30000);
connection.on('close', () => clearInterval(timer)); // don't forget this
}
3. Unbounded in-memory caches
A plain object or Map used as a cache with no eviction policy will grow forever if the key space is unbounded (user IDs, request URLs, session tokens). Use an LRU cache with a max size, or a WeakMap when the key is an object whose lifecycle you don’t control — entries are automatically collected once the key object itself is no longer referenced elsewhere.
// Unbounded: grows forever as new users appear
const userCache = new Map();
// Better: cap size, evict oldest entries
import { LRUCache } from 'lru-cache';
const userCache = new LRUCache({ max: 5000 });
4. Closures capturing large objects unintentionally
A closure captures its entire enclosing scope, not just the variables it uses. If a small, frequently-created function accidentally closes over a large buffer or array from an outer scope, every instance of that function keeps the large object alive.
Finding a Leak with Heap Snapshots
Start the process with the inspector enabled, then connect Chrome DevTools:
node --inspect server.js
# Open chrome://inspect in Chrome, click "inspect" under Remote Target
Under the Memory tab, take a heap snapshot, generate load against the suspected endpoint, force garbage collection, then take a second snapshot. Use the “Comparison” view between the two snapshots — objects with a large positive delta in retained size that shouldn’t still exist point directly at the leak. The Retainers panel for a specific object shows exactly which reference chain is keeping it alive, which is usually enough to trace it back to the offending .on(), closure, or cache.
For production services where attaching DevTools isn’t practical, tools like clinic doctor and heapdump let you capture a snapshot programmatically and analyze it offline:
npx clinic doctor -- node server.js
Preventing Leaks Going Forward
- Pair every
.on()with a corresponding.off()/.removeListener(), especially inside per-request or per-connection handlers. - Pair every
setInterval/setTimeoutwith aclearInterval/clearTimeouttied to the resource’s lifecycle. - Cap the size of any in-memory cache, or use
WeakMap/WeakRefwhen keys are objects you don’t own. - Set
process.on('warning', ...)to catch Node’s ownMaxListenersExceededWarning, which often surfaces listener leaks before they become a crisis. - Run a load test against staging while watching
heapUsedover time before shipping anything that adds new long-lived event subscriptions or caches.
Conclusion
Memory leaks in Node.js are almost always a reference you forgot to release, not a flaw in the runtime itself. The fix is rarely complicated once you’ve found it — the real work is building the habit of profiling long-running processes under realistic load, and treating every .on(), timer, and cache as a resource that needs an explicit release plan.