Node.js Garbage Collection and Event Listeners

In Node.js, managing memory efficiently is crucial for building scalable applications. This article explains how the V8 JavaScript engine handles garbage collection for objects associated with active event listeners, detailing why active listeners can prevent garbage collection, how this leads to memory leaks, and the best practices for preventing these issues.

The Core Mechanism: Reachability

The V8 engine, which powers Node.js, manages memory using a garbage collection (GC) strategy based on reachability. An object is kept in memory as long as it is “reachable” from the root (such as the global object or the current execution stack). If an object can no longer be accessed by any path of references from the root, it is considered garbage and its memory is reclaimed during the next GC cycle.

How Event Listeners Affect Reachability

An event listener is a callback function registered to react to specific events emitted by an EventEmitter. When you attach a listener to an emitter using methods like .on() or .addListener(), a strong reference chain is created:

  1. The Emitter to the Listener: The event emitter stores a reference to the listener function in an internal array of listeners.
  2. The Listener to its Scope: Because JavaScript functions are closures, the listener function retains a reference to its lexical environment (the scope in which it was created). This scope often includes the object that created the listener.

As long as the event emitter itself is reachable from the root, all of its registered listeners—and any objects those listeners reference via closures—are also considered reachable. They will not be garbage collected.

The Memory Leak Scenario

A memory leak occurs when short-lived objects are prevented from being garbage collected because they are referenced by long-lived objects.

Consider this common scenario:

const EventEmitter = require('events');
const globalEmitter = new EventEmitter(); // Long-lived object

class Session {
  constructor(id) {
    this.id = id;
    // Attaching a listener to a long-lived global object
    globalEmitter.on('update', () => {
      console.log(`Session ${this.id} updated`);
    });
  }
}

// Creating and discarding sessions
let userSession = new Session(1);
userSession = null; // Intentional discard

In the example above, even though userSession is set to null, the Session instance remains in memory. This is because globalEmitter (a long-lived object) holds a reference to the listener arrow function, which in turn holds a reference to this (the Session instance) via closure.

Because globalEmitter is reachable from the root, the Session instance cannot be garbage collected. If this process is repeated, memory usage will grow continuously.

How to Properly Clean Up Event Listeners

To allow Node.js to garbage collect objects with event listeners, you must break the reference chain when the objects are no longer needed.

1. Explicitly Remove Listeners

When an object is being disposed of, always remove its listeners from any external emitters using .removeListener() or .off().

class Session {
  constructor(id) {
    this.id = id;
    this.listener = () => console.log(`Session ${this.id} updated`);
    globalEmitter.on('update', this.listener);
  }

  destroy() {
    // Break the reference chain
    globalEmitter.off('update', this.listener);
  }
}

let userSession = new Session(1);
// When finished:
userSession.destroy();
userSession = null; // Now eligible for garbage collection

2. Use once() for Single-Use Events

If a listener only needs to respond to an event a single time, register it using .once(). Node.js automatically removes the listener after it is fired, breaking the reference chain without manual intervention.

globalEmitter.once('temp-event', () => {
  console.log('This runs once and is automatically cleaned up');
});

3. Leverage AbortController

Modern Node.js APIs support the AbortSignal pattern to clean up event listeners dynamically. This is highly effective when managing multiple event listeners.

const controller = new AbortController();
const { signal } = controller;

globalEmitter.on('update', () => {
  console.log('Updated');
}, { signal });

// Cancel all listeners associated with this signal
controller.abort();