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

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

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:

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.

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.