EventEmitter instances are leaking memory because listeners are being added but never removed, or the EventEmitter itself isn’t being garbage collected because it’s still referenced by its listeners.

Here’s how to find and fix those leaks:

Common Causes and Fixes

  1. Listeners Added Without Removal:

    • Diagnosis: Run your application with the --trace-warnings flag. Node.js will then print stack traces for unhandled promise rejections and, crucially for us, for MaxListenersExceededWarning. This warning indicates that an EventEmitter has more listeners registered than its maxListeners limit (defaulting to 10). While this isn’t a leak in itself, it’s a strong indicator of a pattern that leads to leaks if not managed. To confirm a leak, use process.memoryUsage() before and after a period of activity, and check if heapUsed is steadily increasing. You can also use the node --inspect flag to open Chrome DevTools, navigate to the "Memory" tab, take a heap snapshot, trigger some application logic, and take another snapshot. Compare the snapshots for an increase in EventEmitter instances or objects holding many listeners.
    • Fix: Implement a cleanup mechanism. This typically involves calling emitter.removeListener(eventName, listenerFunction) when the listener is no longer needed. If the EventEmitter is part of a component or object that has a lifecycle (e.g., a class instance), remove the listeners in its destroy() or close() method.
      const myEmitter = new EventEmitter();
      const listener = () => console.log('Event!');
      
      myEmitter.on('data', listener);
      
      // Later, when the listener is no longer needed:
      myEmitter.removeListener('data', listener);
      
    • Why it works: Explicitly removing the listener breaks the reference chain. The EventEmitter no longer holds a pointer to the listener function, and if the EventEmitter itself is no longer referenced elsewhere, it can be garbage collected.
  2. EventEmitters Not Being Garbage Collected:

    • Diagnosis: This is the core leak. You’ll see a continuous increase in memory usage with process.memoryUsage().heapUsed over time. Using Chrome DevTools heap snapshots is key here. Look for EventEmitter instances (or objects that contain EventEmitters) that persist across multiple snapshots even though the application logic suggests they should have been discarded. The snapshot comparison will show an increasing number of these objects and their associated listeners.
    • Fix: Ensure that the EventEmitter instance itself is being garbage collected. This means no other active objects or closures should hold a reference to it. If the EventEmitter is a member of a class, make sure the class instance is properly disposed of and its EventEmitter member is either nulled out or the EventEmitter is destroyed.
      class MyService {
          constructor() {
              this.emitter = new EventEmitter();
              this.dataListener = this._handleData.bind(this);
              this.emitter.on('data', this.dataListener);
          }
      
          _handleData(data) {
              console.log('Received:', data);
          }
      
          // Crucial cleanup method
          destroy() {
              this.emitter.removeListener('data', this.dataListener);
              // If the emitter has other listeners, ensure they are also removed.
              // Optionally, if the emitter itself is no longer needed by anything else:
              // this.emitter.removeAllListeners();
              this.emitter = null; // Break the reference to the emitter
          }
      }
      
      let service = new MyService();
      // ... use service ...
      service.destroy(); // Call this when the service is no longer needed
      service = null; // Also help GC by breaking the reference to the service instance
      
    • Why it works: By calling destroy() and setting service = null, we ensure that the MyService instance and its internal emitter are no longer reachable by the running application. The removeListener call within destroy cleans up the EventEmitter’s internal listener array, and setting this.emitter = null breaks the reference from the MyService instance to the EventEmitter. This allows the garbage collector to reclaim the memory used by both the MyService and the EventEmitter.
  3. Global Event Emitters or Singletons:

    • Diagnosis: If you have a global EventEmitter (e.g., global.myAppEvents = new EventEmitter()) that listeners are attached to, and these listeners are closures that capture other objects, those objects might not be garbage collected. Heap snapshots will show the global emitter with a persistent, large number of listeners, and the objects held within those listeners’ closures will also be retained.
    • Fix: Treat singletons like any other object with a lifecycle. If the global emitter is truly global and meant to live forever, you must ensure that listeners attached to it are either very short-lived or do not hold strong references to other objects that should be garbage collected. Often, a better pattern is to pass specific emitters down to components that need them, rather than relying on a single global one. If you must use a global, ensure listeners are removed when the component that registered them is destroyed.
      // Instead of:
      // global.myAppEvents.on('user:login', () => console.log(userProfile.name)); // userProfile leaks
      
      // Consider:
      class UserSession {
          constructor(userProfile) {
              this.userProfile = userProfile;
              this.loginListener = () => console.log(this.userProfile.name);
              // Pass the specific emitter, or access it via a controlled channel
              appEvents.on('user:login', this.loginListener);
          }
          destroy() {
              appEvents.removeListener('user:login', this.loginListener);
          }
      }
      
    • Why it works: By managing the lifecycle of the object registering the listener (e.g., UserSession), you ensure that the listener is removed when it’s no longer relevant, preventing the listener and any captured objects from being held by the global emitter.
  4. Improper Use of once():

    • Diagnosis: While once() is designed to remove the listener after the first invocation, if the EventEmitter itself is never garbage collected, the listener registered with once() will also be retained indefinitely, even though it will only ever be called once. Heap snapshots might show listeners registered with once() that have already fired but are still attached to a long-lived EventEmitter.
    • Fix: once() is generally safe for short-lived scenarios. The issue arises when the EventEmitter it’s attached to is long-lived and the listener holds references to other objects. The fix is the same as for general EventEmitter garbage collection: ensure the EventEmitter instance is properly disposed of. If you find yourself needing to remove a once listener before it fires, you’ll need to store the listener function and call removeListener explicitly.
      const emitter = new EventEmitter();
      const myOnceListener = () => console.log('This will only run once');
      
      emitter.once('data', myOnceListener);
      
      // ... later, if you decide to cancel it before it fires:
      emitter.removeListener('data', myOnceListener);
      
    • Why it works: removeListener explicitly breaks the link, allowing the listener function to be garbage collected if it’s no longer referenced elsewhere, and contributing to the potential garbage collection of the EventEmitter itself.
  5. Circular References (Less Common with EventEmitter directly, but possible):

    • Diagnosis: A classic memory leak pattern. Object A references Object B, and Object B references Object A. If one of these objects is an EventEmitter holding listeners that reference the other object, a leak can occur. Heap snapshots will show two or more objects mutually referencing each other, preventing garbage collection.
    • Fix: Break the cycle. This usually involves introducing a weak reference or explicitly nullifying one of the references when an object is no longer needed. For EventEmitters, this means ensuring that the listener function (or the object it closes over) does not hold a strong reference back to the EventEmitter if the EventEmitter is also holding a strong reference to that listener.
      class A {
          constructor(bInstance) {
              this.b = bInstance;
              this.emitter = new EventEmitter();
              this.listener = this.handleBEvent.bind(this);
              this.emitter.on('bEvent', this.listener);
          }
      
          handleBEvent() {
              // This method might implicitly reference 'this.b'
              console.log('Event from B');
          }
      
          destroy() {
              this.emitter.removeListener('bEvent', this.listener);
              this.emitter = null;
              // If 'b' also held a reference to 'this', it needs to be broken.
              // e.g., if B's constructor was:
              // constructor(aInstance) { this.a = aInstance; ... }
              // Then B would need a destroy() that does: this.a = null;
              this.b = null; // Break cycle if B holds 'this'
          }
      }
      
      // If B also has a reference to A, and A has a reference to B
      // let b = new B(a); // This creates the cycle if B constructor takes A
      // let a = new A(b);
      // a.destroy(); // Must be called to break the cycle
      // b.destroy(); // And this one too if needed
      
    • Why it works: By explicitly setting references to null in a destroy or dispose method, you break the circular dependency. Once neither object can be reached from the root (global objects, stack), the garbage collector can reclaim them.
  6. Error objects not being handled correctly:

    • Diagnosis: When an EventEmitter emits an 'error' event and there are no listeners for it, Node.js will typically throw an unhandled exception. If you do have a listener, but that listener itself leaks memory or doesn’t properly clean up references, the EventEmitter might be retained. Heap snapshots would show the EventEmitter with an 'error' event listener that persists.
    • Fix: Always ensure there’s a listener for the 'error' event on any EventEmitter that might emit one. If you don’t want to handle the error specifically, add a no-op listener or a listener that re-emits it on a higher-level emitter that is handled.
      const stream = fs.createReadStream('nonexistent.txt');
      
      // Without this, Node.js throws an unhandled error.
      stream.on('error', (err) => {
          console.error('Stream error occurred:', err.message);
          // If this listener or the stream object itself is supposed to be short-lived,
          // ensure it's properly cleaned up.
      });
      
    • Why it works: Providing a listener prevents the unhandled exception. If the listener itself is part of a component that needs cleanup, ensure that cleanup happens to prevent the EventEmitter (the stream in this case) from being held unnecessarily.

The next error you’ll likely encounter after fixing these memory leaks is related to event handling logic itself, perhaps an infinite loop if events are being emitted too rapidly, or a race condition if listeners are not properly synchronized.

Want structured learning?

Take the full Nodejs course →