Back/Module P-15 Memory Leak Prevention Patterns
Module P-15·24 min read

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.

Module P-15 — Memory Leak Prevention Patterns

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 four patterns behind 95% of Node.js memory leaks:

  1. Event listener accumulation
  2. Closure scope retaining large objects
  3. Unbounded in-memory caches
  4. Circular references preventing GC

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.

The bug:

javascript
// 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. process.getMaxListeners() will warn at 11, but warnings don't stop the leak.

The fix:

javascript
app.get('/stream', (req, res) => { function onData(chunk) { res.write(chunk) } someGlobalEmitter.on('data', onData) req.on('close', () => { someGlobalEmitter.off('data', onData) // ← remove listener on cleanup res.end() }) })

Detection:

javascript
// 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:

javascript
// Instead of on() + manual off() emitter.once('connected', () => { // Automatically removed after first call })

AbortSignal-aware listeners:

javascript
// EventTarget.addEventListener with AbortSignal for automatic cleanup const controller = new AbortController() someEmitter.addEventListener('data', handler, { signal: controller.signal }) // Later: cleanup all listeners registered with this controller controller.abort()

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
function processUpload(req, res) { const fileBuffer = req.file.buffer // 10MB buffer // 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 processFile(fileBuffer) .then(() => res.json({ success: true })) }

The fix: capture only what you need

javascript
function processUpload(req, res) { const fileBuffer = req.file.buffer const userId = req.user.id // capture only the primitive you need // This closure only captures userId, NOT the entire scope const logRequest = () => { console.log('Processing complete for', userId) } // fileBuffer can now be GC'd when processFile() completes auditLog.push(logRequest) // only userId is retained processFile(fileBuffer) .then(() => res.json({ success: true })) }

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
// This is a memory leak masquerading as a cache const responseCache = new Map() app.get('/api/product/:id', async (req, res) => { const { id } = req.params if (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) })

With 100,000 products, this cache holds 100,000 entries with no eviction policy.

Fix: LRU cache with bounded size

javascript
import { LRUCache } from 'lru-cache' const responseCache = new LRUCache({ max: 1000, // maximum 1000 entries ttl: 1000 * 60 * 5, // entries expire after 5 minutes maxSize: 50_000_000, // maximum 50MB total size sizeCalculation: (value) => JSON.stringify(value).length, }) app.get('/api/product/:id', async (req, res) => { const { id } = req.params const 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) })

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
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

javascript
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:

javascript
// Instead of attaching properties directly to request objects // (which can conflict with framework internals and prevent GC) const requestMetadata = new WeakMap() 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

The Memory Leak Test Pattern

Verify a suspected memory leak with a repetition test:

javascript
// memory-leak.test.ts import { processRequest } from '../src/handlers' async function measureHeap() { if (global.gc) global.gc() // trigger GC before measuring return 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 caches for (let i = 0; i < 10; i++) { await processRequest(mockReq, mockRes) } const heapBefore = await measureHeap() // Run 1000 iterations for (let i = 0; i < 1000; i++) { await processRequest(mockReq, mockRes) } const heapAfter = await measureHeap() const leakPerRequest = (heapAfter - heapBefore) / 1000 // Allow up to 1KB average growth per request (caches, etc.) expect(leakPerRequest).toBeLessThan(1024) }, 30_000)

Run with --expose-gc: node --expose-gc node_modules/.bin/jest memory-leak.test.ts


Monitoring in Production

javascript
// Add to your application — emit memory metrics every 30s setInterval(() => { 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 total if (heapUsed / heapTotal > 0.8) { logger.warn('Heap pressure high', { heapUsed, heapTotal }) } }, 30_000)

Set pod memory limits at 2x your typical heap usage. Alert at 70% of limit. Kill and restart at 90%.

Discussion