Listeners and timers can keep JavaScript objects reachable long after your application no longer needs them. In Node.js, that can mean a request-scoped object, cache, or service remains in memory because a long-lived event emitter or active timer still references it. This guide shows how to find those references, clean them up, and check whether memory use improves.
Key Takeaways
- A listener can retain the objects its callback closes over. Remove it when its owner is finished, using the same callback function you registered.
- Active timers keep their callbacks eligible to run. Clear them when their work is no longer needed;
unref()is not a substitute for cleanup. - Compare memory across repeated workloads, then use heap snapshots to find retaining paths. A single memory reading is rarely enough to diagnose a leak.
- Cleanup should be explicit and safe to call more than once.
How listeners and timers retain memory
The garbage collector can reclaim an object only when it is no longer reachable from live references. A long-lived EventEmitter can retain a listener; the listener can retain its closure variables. Likewise, a timer retains its callback while it remains active, and the callback may retain an entire object graph.
For example, if a process-wide emitter stores a callback belonging to a short-lived request handler, the handler and the data it captured can remain reachable until that listener is removed. The same problem occurs when an interval is created for a component or job but never cleared.
This is not automatically a leak: a listener or timer that is intentionally active for the lifetime of the application is expected. It becomes a leak when its lifetime exceeds the work or object that needs it.
Remove listeners when their owner is done
Keep a reference to the exact callback passed to on(). Create it once, then use it with removeListener() during cleanup. This class also clears its interval and makes dispose() safe to call repeatedly:
const { EventEmitter } = require('node:events');
class Dashboard {
constructor(bus) {
this.bus = bus;
this.onUpdate = this.onUpdate.bind(this);
this.bus.on('update', this.onUpdate);
this.refreshTimer = setInterval(() => {
this.refresh();
}, 30_000);
}
onUpdate(data) {
// Handle the update while this dashboard is active.
console.log('Update received:', data);
}
refresh() {
// Refresh this dashboard's data.
}
dispose() {
this.bus.removeListener('update', this.onUpdate);
if (this.refreshTimer !== undefined) {
clearInterval(this.refreshTimer);
this.refreshTimer = undefined;
}
}
}
const bus = new EventEmitter();
const dashboard = new Dashboard(bus);
// Call when the dashboard's owner no longer needs it.
dashboard.dispose();
Binding the method in the constructor gives the instance a stable function reference. This will not work as intended:
bus.on('update', this.onUpdate.bind(this));
bus.removeListener('update', this.onUpdate.bind(this));
Each call to bind() creates a different function, so the second call does not identify the registered listener. Store the bound function once, or define a named callback and keep that reference. removeListener() is also available as off() in supported Node.js versions; check the EventEmitter API for the version you use.
For one-time events, once() automatically removes the listener after it fires. It is useful when the event is guaranteed to occur; if it might never occur, the listener can still remain attached indefinitely.
Clear timers when their work ends
clearInterval() cancels a repeating interval, and clearTimeout() cancels a timeout that has not fired. Store the timer handle so the code that owns the work can clear it. If a timeout schedules another timeout, stop the rescheduling chain when the owner is disposed.
Calling timer.unref() only tells Node.js that this timer alone need not keep the process running. It does not cancel the timer or release its callback while the timer remains active. Use clearTimeout() or clearInterval() for cleanup. See the Node.js timers documentation.
Choose the cleanup boundary that matches the timer's owner: a request, job, connection, or application service. For work that may fail, put cleanup in a finally block so errors do not skip it:
const timer = setInterval(runScheduledWork, 1_000);
try {
await runTask();
} finally {
clearInterval(timer);
}
This pattern assumes runTask() represents the lifetime of the work that owns the interval. If the timer must outlive that task, give it a longer-lived owner and provide that owner with an explicit stop method instead.
Measure memory before and after the fix
Start by reproducing the same workload several times. Record memory after each run and after the application has had time to settle. A growing trend across equivalent cycles is more informative than one high reading.
process.memoryUsage() returns several useful values:
heapUsedandheapTotaldescribe V8's JavaScript heap usage and allocation.externalaccounts for memory associated with native objects bound to JavaScript objects.arrayBuffersreports memory forArrayBufferandSharedArrayBufferallocations; it is included inexternal.rssis total resident memory for the process, including JavaScript and native memory.
A small monitoring sample:
setInterval(() => {
const memory = process.memoryUsage();
console.log({
heapUsedMiB: Math.round(memory.heapUsed / 1024 / 1024),
heapTotalMiB: Math.round(memory.heapTotal / 1024 / 1024),
externalMiB: Math.round(memory.external / 1024 / 1024),
rssMiB: Math.round(memory.rss / 1024 / 1024),
});
}, 10_000);
Treat these readings as signals, not proof. V8 may retain allocated heap for reuse, and RSS can stay elevated even after JavaScript objects become collectible. If heapUsed is stable but RSS rises, investigate native allocations and buffers as well as JavaScript references. See process.memoryUsage() for field details.
Use heap snapshots to find retaining paths
When memory keeps growing, take a heap snapshot before the workload and another after repeating it. In Chrome DevTools, connect to a locally running Node.js process started with node --inspect app.js, open the Memory panel, and capture snapshots. Compare the snapshots and inspect objects that accumulate. The retaining path shows what is keeping an object reachable; look for listener callbacks, timer callbacks, emitter instances, and closures containing unexpectedly large data.
Snapshots are diagnostic artifacts, not free observations. Capturing one can pause the process and require substantial additional memory, potentially causing a memory-constrained process to fail. Use a controlled environment when possible, protect snapshots because they may contain sensitive application data, and avoid exposing the inspector to untrusted networks. Node.js documents the workflow in Using Heap Snapshot.
A useful verification loop is:
- Reproduce the suspected leak with a repeatable workload.
- Capture a baseline snapshot and note memory metrics.
- Run the workload several times, then capture another snapshot.
- Identify objects that accumulate and follow their retaining paths to the owning listener or timer.
- Add cleanup at the correct lifecycle boundary and repeat the same workload.
- Confirm that those retained objects no longer accumulate. Allow for normal variation and heap reuse rather than expecting RSS to immediately return to its starting value.
Common Mistakes
- Registering anonymous callbacks that cannot be removed easily. Save the callback reference, or use a lifecycle pattern that owns and cleans up the listener.
- Removing a different function than the one registered. Repeatedly calling
.bind()or creating a new arrow function creates a new callback identity. - Assuming
once()always prevents retention. It cleans up after the event fires, but not if the event never arrives. - Using
unref()as a leak fix. It affects whether a timer keeps the process alive; it does not stop the callback or clear the timer. - Treating listener warnings as a leak detector. An EventEmitter warning about many listeners can reveal a problem, but the absence of a warning does not prove cleanup is correct. A legitimate emitter may also have many listeners.
- Judging a fix from one memory sample. Heap allocation and RSS vary. Compare repeated, equivalent workloads and inspect snapshots when necessary.
Best Practices
- Give listeners and timers an explicit owner and a clear end-of-life operation such as
dispose()orstop(). - Keep callback and timer-handle references where cleanup code can reach them.
- Make cleanup idempotent so shutdown paths and error handling can safely call it more than once.
- Use
try/finallyor equivalent lifecycle hooks so exceptions do not bypass cleanup. - Test lifecycle behavior: create the object, exercise it, dispose it, and verify it no longer reacts to events or runs scheduled callbacks.
- Diagnose the retaining path before changing code. Avoid indiscriminately removing listeners from shared emitters, which can break other consumers.
Conclusion
Event listeners and timers cause memory leaks when their references outlive the work they serve. Track their ownership, remove listeners using the original callback, and clear timer handles at the right lifecycle boundary. Verify the change with repeated workloads, memory metrics, and heap snapshots rather than relying on a single reading.
