Module A-2·17 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.

Written by Jatin Jain Saraf · Architect of SupraScan

Module A-2 — 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 five patterns behind the overwhelming majority 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
  5. 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:

// 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)
  }
 
  someGlobalEmitter.on('data', onData)
 
  req.on('close', () => {
    someGlobalEmitter.off('data', onData)  // ← remove listener on cleanup
    res.end()
  })
})

Detection:

// 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()

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:

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

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:

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

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.