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.
Why Node.js Memory Leaks Are Different
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:
Event listener accumulation
Closure scope retaining large objects
Unbounded in-memory caches
Circular references preventing GC
Uncleared timers retaining closures
Pattern 1: Event Listener Accumulation
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:
javascript
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:
javascript
Detection:
javascript
The once() pattern for event listeners that should fire exactly once:
javascript
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:
javascript
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:
javascript
Pattern 2: Closure Scope Retaining Large Objects
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.
The bug:
javascript
The fix: capture only what you need
javascript
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.
Pattern 3: Unbounded In-Memory Caches
An in-memory Map that grows forever is a memory leak with a polite name. It's a cache until it's a problem, then it's an OOM.
The bug:
javascript
With 100,000 products, this cache holds 100,000 entries with no eviction policy.
Fix: LRU cache with bounded size
javascript
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.
Pattern 4: Circular References
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:
javascript
Fix: WeakRef for non-owning references
javascript
WeakMap for metadata attached to objects you don't own:
javascript
Pattern 5: Uncleared Timers Retaining Closures
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:
javascript
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
javascript
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.
Off-Heap Leaks: external and rss Growth Without heapUsed Moving
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.
Finding a Leak After It Exists: Heap-Snapshot Diffing
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():
javascript
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.
The Memory Leak Test Pattern
Verify a suspected memory leak with a repetition test:
javascript
Run with --expose-gc: node --expose-gc node_modules/.bin/jest memory-leak.test.ts
Monitoring in Production
javascript
Set pod memory limits at 2x your typical heap usage. Alert at 70% of limit. Kill and restart at 90%.
The Production Incident: Listener Leak From a Connection Storm
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:
javascript
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.
// Express route handler — creates a new listener per requestapp.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})})
// Add to your app startupprocess.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 earlieremitter.setMaxListeners(20)// warn at 20 instead of 11
// Instead of on() + manual off()emitter.once('connected',()=>{// Automatically removed after first call})
// Real EventTarget (not EventEmitter) — signal is a native option hereconst controller =newAbortController()someEventTarget.addEventListener('data', handler,{signal: controller.signal})// Later: removes the listener automaticallycontroller.abort()
const controller =newAbortController()functiononData(chunk){ res.write(chunk)}someGlobalEmitter.on('data', onData)// Manual equivalent of AbortSignal-based cleanup for an EventEmittercontroller.signal.addEventListener('abort',()=>{ someGlobalEmitter.off('data', onData)})// Later: triggers the cleanup abovecontroller.abort()
functionprocessUpload(req, res){const fileBuffer = req.file.buffer// 10MB buffer// This closure captures the ENTIRE scope, including fileBufferconstlogRequest=()=>{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 indefinitelyprocessFile(fileBuffer).then(()=> res.json({success:true}))}
functionprocessUpload(req, res){const fileBuffer = req.file.bufferconst userId = req.user.id// capture only the primitive you need// This closure only captures userId, NOT the entire scopeconstlogRequest=()=>{console.log('Processing complete for', userId)}// fileBuffer can now be GC'd when processFile() completes auditLog.push(logRequest)// only userId is retainedprocessFile(fileBuffer).then(()=> res.json({success:true}))}
// This is a memory leak masquerading as a cacheconst responseCache =newMap()app.get('/api/product/:id',async(req, res)=>{const{ id }= req.paramsif(responseCache.has(id)){return res.json(responseCache.get(id))}const product =await db.products.findUnique({where:{ id }}) responseCache.set(id, product)// grows forever — never evicted res.json(product)})
import{LRUCache}from'lru-cache'const responseCache =newLRUCache({max:1000,// maximum 1000 entriesttl:1000*60*5,// entries expire after 5 minutesmaxSize:50_000_000,// maximum 50MB total sizesizeCalculation:(value)=>JSON.stringify(value).length,})app.get('/api/product/:id',async(req, res)=>{const{ id }= req.paramsconst cached = responseCache.get(id)if(cached)return res.json(cached)const product =await db.products.findUnique({where:{ id }}) responseCache.set(id, product) res.json(product)})
classRequestContext{constructor(req){this.req= req
this.logger=newLogger(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 memoryconst activeRequests =newMap()activeRequests.set(req.id,newRequestContext(req))// Never cleaned up → permanent retention
classLogger{ #context // WeakRef — doesn't prevent GC of the contextconstructor(context){this.#context=newWeakRef(context)}log(message){const ctx =this.#context.deref()if(!ctx)return// context was GC'd, log silentlyconsole.log(`[${ctx.requestId}] ${message}`)}}
// Instead of attaching properties directly to request objects// (which can conflict with framework internals and prevent GC)const requestMetadata =newWeakMap()app.use((req, res, next)=>{ requestMetadata.set(req,{startTime:Date.now(),userId:null})next()})// Metadata is automatically GC'd when req is GC'd// No manual cleanup required
// Per-connection heartbeat — created for every WebSocket connectionfunctionhandleConnection(socket){const state ={ socket,lastSeen:Date.now(),buffer:[]}// This interval closure captures `state` (and therefore `socket`) foreverconst heartbeat =setInterval(()=>{if(Date.now()- state.lastSeen>30_000){ socket.terminate()} socket.ping()},5000) 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}
functionhandleConnection(socket){const state ={ socket,lastSeen:Date.now(),buffer:[]}const heartbeat =setInterval(()=>{if(Date.now()- state.lastSeen>30_000){ socket.terminate()} socket.ping()},5000) socket.on('message',()=>{ state.lastSeen=Date.now()})// 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))}
importv8from'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 viewv8.writeHeapSnapshot('./before.heapsnapshot')awaitdriveSuspectedLeakPath(1000)// run whatever you suspect is leaking, 1000xawaitsleep(2000)// let any pending GC settlev8.writeHeapSnapshot('./after.heapsnapshot')
// memory-leak.test.tsimport{ processRequest }from'../src/handlers'asyncfunctionmeasureHeap(){if(global.gc) global.gc()// trigger GC before measuringreturn process.memoryUsage().heapUsed}it('does not leak memory across 1000 requests',async()=>{const mockReq =createMockRequest()const mockRes =createMockResponse()// Warm up — first few runs may allocate cachesfor(let i =0; i <10; i++){awaitprocessRequest(mockReq, mockRes)}const heapBefore =awaitmeasureHeap()// Run 1000 iterationsfor(let i =0; i <1000; i++){awaitprocessRequest(mockReq, mockRes)}const heapAfter =awaitmeasureHeap()const leakPerRequest =(heapAfter - heapBefore)/1000// Allow up to 1KB average growth per request (caches, etc.)expect(leakPerRequest).toBeLessThan(1024)},30_000)
// Add to your application — emit memory metrics every 30ssetInterval(()=>{const{ heapUsed, heapTotal, external, rss }= process.memoryUsage() metrics.gauge('nodejs.heap.used', heapUsed) metrics.gauge('nodejs.heap.total', heapTotal) metrics.gauge('nodejs.external', external) metrics.gauge('nodejs.rss', rss)// Alert if heap grows beyond 80% of totalif(heapUsed / heapTotal >0.8){ logger.warn('Heap pressure high',{ heapUsed, heapTotal })}},30_000)
functionsubscribe(socket, globalEmitter){functiononData(chunk){ socket.write(chunk)} globalEmitter.on('data', onData)functioncleanup(){ 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 independentlyconst liveness =setInterval(()=>{if(socket.destroyed||!socket.writable){cleanup()clearInterval(liveness)}},15_000) socket.on('close',()=>clearInterval(liveness))}