Memory leaks from event listeners: what AI-generated Node.js code gets wrong
Por Seaworthy · 30 de junio de 2026
Photo by Taylor Vick on Unsplash
There is a particular kind of production incident I have come to dread. The service was running fine for weeks. Then, slowly, memory climbs. Response times stretch. Eventually the process keels over, and you are left staring at heap dumps at 2am wondering what happened.
The culprit, more often than I would like, is an event listener that nobody cleaned up.
This problem is getting worse. AI code generators are confident and fast. They produce on('event', handler) registrations everywhere and almost never generate the matching off() or removeListener() call. The code looks correct. It passes review. It runs fine in a five-minute local test. Then it ships.
What actually leaks
Node.js event emitters hold references to every listener you attach. If the emitter outlives the object that registered the listener, and you never explicitly remove that listener, the emitter keeps the handler, its closure, and everything the closure captured in memory. Forever.
Here is a pattern I see constantly in AI-generated service code:
class ConnectionManager extends EventEmitter {
constructor(private db: Database) {
super();
}
watch(repo: Repository): void {
// listener added, never removed
this.db.on('connect', () => {
repo.refresh(); // repo captured in closure
});
this.db.on('error', (err) => {
repo.handleError(err); // and again here
});
}
}
Every call to watch() registers two new listeners on this.db. The repo object is captured in each closure. When repo goes out of scope in caller code, it cannot be garbage-collected because the listeners still hold a reference. Call watch() a hundred times and you have a hundred orphaned repo objects and two hundred listeners piling up.
Node.js will warn you about this. The default MaxListenersExceededWarning fires at eleven listeners. That warning is treated as noise by most teams. It should not be.
Why closures make it worse
The closure problem compounds the listener problem. A closure captures the entire variable environment at the point it is created. That environment might include request objects, database connections, or large data structures. Each uncleaned listener is not just a dangling function pointer. It is an anchor that prevents a whole graph of objects from being freed.
AI tools do not model object lifetimes. They generate code that is structurally correct at the statement level. They do not ask: what is the lifetime of this emitter relative to the lifetime of this handler? That question requires understanding runtime behaviour, and language models are not doing that.
The fix is explicit, paired cleanup
The corrected pattern uses named handler references and pairs every on with a cleanup path:
class ConnectionManager extends EventEmitter {
private handlers = new Map<
Repository,
{ connect: () => void; error: (e: Error) => void }
>();
constructor(private db: Database) {
super();
}
watch(repo: Repository): void {
const connectHandler = () => repo.refresh();
const errorHandler = (err: Error) => repo.handleError(err);
this.handlers.set(repo, { connect: connectHandler, error: errorHandler });
this.db.on('connect', connectHandler);
this.db.on('error', errorHandler);
}
unwatch(repo: Repository): void {
const h = this.handlers.get(repo);
if (!h) return;
this.db.off('connect', h.connect);
this.db.off('error', h.error);
this.handlers.delete(repo);
}
}
This version stores handler references so they can be removed later. unwatch() is not optional boilerplate. It is the other half of the contract. Callers that forget to call it still leak, but at least the mechanism for cleanup exists and can be enforced in code review.
For long-running services, reach for once() instead of on() when you only need the event fired one time. once() automatically removes the listener after invocation. It solves a narrower problem but it costs nothing when applicable.
What to look for in code review
If you are reviewing AI-generated Node.js code, find any emitter.on() call and ask: where is the corresponding off()? If there is not one, probe the caller to understand when the listener should be removed.
Watch specifically for event registration inside constructors, factory functions, or any method that might be called more than once per process lifetime. These are the places accumulation happens silently.
Also pay attention to AbortController and AbortSignal. The same pattern applies: signal.addEventListener('abort', handler) without a matching removeEventListener leaks the handler into the signal's lifetime. This shows up constantly in AI-generated fetch wrappers.
Heap profiling catches these leaks, but only after they have already happened. The --inspect flag and Chrome DevTools heap snapshots will show you objects retained by EventEmitter listener arrays. That is reactive diagnosis. Catching the pattern in code review is far cheaper.
Memory leaks are an authorship problem
The uncomfortable truth is that AI tools optimise for generating code that compiles. They are not accountable for what happens when that code runs under production load for thirty days. That accountability still sits with the humans who ship it.
Event listeners are contracts: you open them, you close them. Closures are anchors: you need to know what they hold and for how long. AI tools generate the opening without thinking about the closing, and they generate closures without auditing what they capture.
Seaworthy's static analysis checks for unremoved event listener registrations and unbounded closure captures in Node.js service code, so these patterns get flagged before they reach production.
Este artículo fue generado por IA y resume fuentes públicamente disponibles.
Seaworthy analiza tu repositorio en busca de los problemas que se tratan en artículos como este.
Brechas de seguridad, secretos expuestos y configuraciones erróneas detectados antes de desplegar. Gratis, sin cuenta necesaria.