The four patterns behind 95% of Node.js memory leaks: event listener accumulation (on() without off() per request), closure scope retaining large objects, unbounded in-memory caches without TTL/LRU, and circular reference traps — with WeakRef/WeakMap solutions and a Jest-compatible memory leak test pattern.
What this module covers: The most expensive Node.js memory leak is the one you ship in your first version and discover 6 weeks later when your pods start OOMing at 3am. clinic.js and flame graphs help you find a leak after it exists. This module teaches you not to write the leak in the first place.
V8's garbage collector handles object lifecycle automatically. Memory leaks in Node.js are not C-style use-after-free bugs. They're retention bugs: objects that are still referenced somewhere in the heap even though the application is done with them. The GC cannot collect what is still reachable.
The five patterns behind the overwhelming majority of Node.js memory leaks:
Every emitter.on('event', handler) creates a reference from the emitter to the handler function. If the emitter outlives the handler's intended scope (which it usually does), the handler is never GC'd. Multiply this by every request that creates a new handler.
An event listener without cleanup is like handing your hotel room key to every guest who ever asks — you never ask for it back, and years later ten thousand keys are floating around with no way to know who might still walk in.
The bug:
// Express route handler — creates a new listener per request app.get('/stream', (req, res) => { // This listener is created for EVERY request and never removed someGlobalEmitter.on('data', (chunk) => { res.write(chunk) })
req.on('close', () => { res.end() // but the listener on someGlobalEmitter is still there }) })
After 1000 requests, someGlobalEmitter has 1000 accumulated listeners. someGlobalEmitter.getMaxListeners() returns the default cap of 10 for that emitter — so Node's MaxListenersExceededWarning fires once the 11th listener is attached — but the warning is just a diagnostic; it doesn't stop the leak, and it's a property of someGlobalEmitter specifically, not of process or of emitters in general.
The fix:
app.get('/stream', (req, res) => { function onData(chunk) { res.write(chunk) }
// Add to your app startup process.on('warning', (warning) => { if (warning.name === 'MaxListenersExceededWarning') { console.error('Memory leak detected: too many listeners', warning.emitter) // Log the emitter type so you know which one is leaking } })
// Raise the warning threshold so it fires earlier emitter.setMaxListeners(20) // warn at 20 instead of 11
The once() pattern for event listeners that should fire exactly once:
// Instead of on() + manual off() emitter.once('connected', () => { // Automatically removed after first call })
AbortSignal-based cleanup — mind the class:
{ signal } cleanup is a WHATWG EventTarget feature (addEventListener(type, handler, { signal })), not a Node EventEmitter feature — EventEmitter.on() has no signal option. If you're working with a real EventTarget (e.g. a MessagePort, an AbortSignal itself, or a Web-standard API surface Node also implements), the built-in pattern works directly:
// Real EventTarget (not EventEmitter) — signal is a native option here const controller = new AbortController() someEventTarget.addEventListener('data', handler, { signal: controller.signal })
// Later: removes the listener automatically controller.abort()
For a Node EventEmitter like someGlobalEmitter in the example above, there's no built-in signal option — wire the abort event to a manual .off() call instead:
const controller = new AbortController()
function onData(chunk) { res.write(chunk) }
someGlobalEmitter.on('data', onData)
// Manual equivalent of AbortSignal-based cleanup for an EventEmitter controller.signal.addEventListener('abort', () => { someGlobalEmitter.off('data', onData) })
// Later: triggers the cleanup above controller.abort()
A closure captures its surrounding scope, not individual variables. If the scope contains a large object and the closure outlives its usefulness, the large object cannot be GC'd.
// This closure captures the ENTIRE scope, including fileBuffer const logRequest = () => { console.log('Processing complete for', req.user.id) // fileBuffer is in scope but never used in logRequest // Yet it cannot be GC'd as long as logRequest is alive }
// logRequest is stored globally for audit purposes auditLog.push(logRequest) // this keeps fileBuffer in memory indefinitely
The rule: in a function that handles large objects (request bodies, file buffers, database result sets), extract only the primitives you need before passing callbacks to longer-lived systems.
Session caches and connection caches: Apply the same pattern. Redis clients cached by connection string: cap at 50. Database connection pools: always use pool.end() on shutdown.
The in-process Map as a shared singleton: If your module exports a Map, it lives for the entire process lifetime. Every test that doesn't clear it accumulates state. Add a clear() function and call it in afterEach in tests.
Modern V8 handles circular references in pure JavaScript objects (two objects referencing each other). GC collects them when the entire cycle becomes unreachable. The problem arises when circular references involve non-GC'd native resources (streams, buffers, timers).
The subtle bug:
class RequestContext { constructor(req) { this.req = req this.logger = new Logger(this) // Logger keeps reference to RequestContext req.context = this // req keeps reference to RequestContext // Circular: RequestContext → req → RequestContext // Also: RequestContext → Logger → RequestContext } }
// If RequestContext is stored in a global registry without cleanup, // the entire cycle is retained in memory const activeRequests = new Map() activeRequests.set(req.id, new RequestContext(req)) // Never cleaned up → permanent retention
Fix: WeakRef for non-owning references
class Logger { #context // WeakRef — doesn't prevent GC of the context
constructor(context) { this.#context = new WeakRef(context) }
log(message) { const ctx = this.#context.deref() if (!ctx) return // context was GC'd, log silently console.log(`[${ctx.requestId}] ${message}`) } }
WeakMap for metadata attached to objects you don't own:
// Instead of attaching properties directly to request objects // (which can conflict with framework internals and prevent GC) const requestMetadata = new WeakMap()
setInterval and setTimeout are event listeners in disguise: the timer holds a reference to its callback closure for as long as the timer is active, and an interval is active until something explicitly calls clearInterval. This is an extremely common leak in real Node.js services, because timers are usually set up once at module load or connection time and easily forgotten.
The bug:
// Per-connection heartbeat — created for every WebSocket connection function handleConnection(socket) { const state = { socket, lastSeen: Date.now(), buffer: [] }
socket.on('message', () => { state.lastSeen = Date.now() }) // No 'close' handler clears the interval — if the socket drops without // a clean close event, `heartbeat` keeps firing forever, keeping `state`, // `socket`, and everything `socket` references alive }
Every connection that disconnects uncleanly leaves its interval running indefinitely — each tick is cheap, but the closure (and the socket, buffers, and auth context it retains) never gets GC'd, and the intervals themselves accumulate as a permanent, growing scheduler workload.
The fix: always pair a timer with its teardown path
function handleConnection(socket) { const state = { socket, lastSeen: Date.now(), buffer: [] }
// Clear on every path that ends the connection's lifetime, not just the happy path socket.on('close', () => clearInterval(heartbeat)) socket.on('error', () => clearInterval(heartbeat)) }
The same pattern applies to setTimeout used for retry/backoff logic — a scheduled retry that outlives the thing it was retrying on behalf of retains that object's closure until the timeout fires or is explicitly cleared with clearTimeout. If the retry is rescheduled recursively (a common pattern for exponential backoff), an unbounded chain of timers can retain an unbounded chain of closures if nothing ever calls clearTimeout on cancellation.
Everything covered so far is a heap leak — objects retained inside V8's managed heap, visible in heapUsed. Not all Node.js memory leaks live there. Buffer allocations backed by native memory (large buffers in particular), native addon allocations, and some zlib/crypto internal state live off the V8 heap, tracked instead in process.memoryUsage().external (and reflected in rss, the process's total resident memory).
This matters because the monitoring code shown later in this module watches heapUsed / heapTotal — a leak that only grows external and rss would sail through that check untouched, since heapUsed never moves. A service can be steadily leaking native buffers, get killed by the OS or orchestrator on an RSS-based memory limit, and the on-call engineer staring at heapUsed metrics sees nothing wrong right up until the OOM kill.
What causes this in practice:
Retaining large Buffers or Uint8Arrays backed by ArrayBuffer allocations beyond their useful life (the same reachability problem as Pattern 2, just for off-heap memory)
Native addons that allocate memory in C/C++ and don't free it correctly on the JS object's finalization
Streaming decompression (zlib) contexts left open
Practical implication: always monitor external and rss alongside heapUsed/heapTotal, not instead of them — a healthy-looking heap doesn't rule out a native memory leak.
The patterns above are about not writing the leak. When you're debugging a leak that's already shipped, the standard technique is a heap snapshot diff — capture the heap at two points in time under the same steady-state conditions, and compare what grew.
Using Chrome DevTools (via the inspector):
Start the process with node --inspect server.js and open chrome://inspect in Chrome, or attach via VS Code's Node debugger.
In the Memory panel, take a heap snapshot (Snapshot 1) after warm-up, before the suspected leak trigger.
Drive the suspected leak path — e.g. run the request/connection pattern under investigation N times.
Take a second snapshot (Snapshot 2).
Use the "Comparison" view between the two snapshots. Sort by "# Delta" (object count growth) or "Retained Size Delta." Objects that grew by exactly N (matching your N iterations) are your leak candidates.
Expand a candidate's retainer tree ("Retainers" panel) to see the reference chain keeping it alive — this is usually where you find the forgotten listener, closure, or Map entry.
Programmatically, in production or CI, using v8.writeHeapSnapshot():
import v8 from 'node:v8' import { setTimeout as sleep } from 'node:timers/promises'
// Take a baseline snapshot, drive load, take a second snapshot, // then diff the two .heapsnapshot files in Chrome DevTools' Comparison view v8.writeHeapSnapshot('./before.heapsnapshot') await driveSuspectedLeakPath(1000) // run whatever you suspect is leaking, 1000x await sleep(2000) // let any pending GC settle v8.writeHeapSnapshot('./after.heapsnapshot')
Load both .heapsnapshot files as tabs in Chrome DevTools' Memory panel and select "Comparison" — the same diff workflow as the interactive case above, but reproducible from a script or CI job rather than a live debugging session. This is the practical follow-through on clinic.js and flame graphs: those tools tell you that memory is growing and roughly where CPU/time is going; heap-snapshot diffing tells you exactly which retained objects are growing and what's holding onto them.
Context: A blockchain indexer's WebSocket layer let clients subscribe to block-confirmation events. Each subscription registered a listener on a shared, global emitter — the same someGlobalEmitter.on('data', ...) pattern shown as the bug in Pattern 1, except the real code did have a cleanup path, wired to the socket's normal close event.
What happened: During a UPI festival-day traffic spike, a large fraction of client connections were on flaky mobile networks. Instead of a clean TCP close/FIN handshake, many connections died via an abrupt RST (reset) — a network-layer termination that, on certain socket configurations, does not reliably fire the application-level close event the cleanup code was relying on. Every one of those resets left its data listener attached to the global emitter, along with everything that listener's closure retained: the dead socket handle, the subscriber's auth context, and any per-connection buffers.
Individually, each leaked listener was a few kilobytes. Under normal churn this would never have been noticed. But the connection storm meant thousands of resets per hour, sustained for several hours during the festival window — each one adding a listener that would never be removed. heapUsed climbed steadily rather than sawtoothing with GC as it did under normal load. The MaxListenersExceededWarning fired constantly (the emitter had long since blown past its default cap) but had been suppressed months earlier as "noisy" without anyone raising setMaxListeners deliberately or investigating why it fired at all. The process eventually hit its container memory limit and was OOM-killed — during the same peak window user traffic needed it most.
The fix: don't rely solely on close for cleanup — handle error and, critically, use a heartbeat/liveness check as a backstop rather than trusting a single event to always fire:
function subscribe(socket, globalEmitter) { function onData(chunk) { socket.write(chunk) }
globalEmitter.on('data', onData)
function cleanup() { globalEmitter.off('data', onData) }
// Multiple teardown paths — don't trust a single event to always fire socket.on('close', cleanup) socket.on('error', cleanup)
// Backstop: if the socket goes quiet without any teardown event firing // (e.g. an RST that the runtime doesn't surface as 'close' in every case), // a liveness timeout catches it independently const liveness = setInterval(() => { if (socket.destroyed || !socket.writable) { cleanup() clearInterval(liveness) } }, 15_000) socket.on('close', () => clearInterval(liveness)) }
The broader lesson: a cleanup path that depends on exactly one event firing is a single point of failure. Treat MaxListenersExceededWarning as a page-worthy signal, not noise to silence — it was the leak announcing itself for months before the incident.
Next: Event Loop Saturation & Thread Pool Offloading
Knowledge Check
Why does failing to call removeListener (or off) on a global EventEmitter within an HTTP request handler lead to a memory leak?
Which of the following is the safest way to store request-specific metadata on an Express request object without risking a memory leak if the request is unexpectedly aborted?
What is the purpose of measuring memory leak potential across multiple requests in a Jest test using --expose-gc and global.gc()?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.