Node.js memory leak from event listeners added per request (MaxListenersExceededWarning)
Our price streaming API grew from 180 MB to 1.4 GB of heap in a day and was restarted by the orchestrator every night. A listener was attached to a shared EventEmitter on every request and never removed. Heap snapshots proved it; removing listeners on close fixed it.
Our quote service streams live FX prices to trading dashboards over Server-Sent Events. After a release in late November, its containers started getting OOM-killed once a day, always in the early hours when traffic was lowest. The memory graph was a straight ramp: about 180 MB after start, roughly 60 MB more per hour, until the 1.5 GiB limit.
The logs had a warning we had seen in the past and ignored:
(node:1) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 tick listeners added to [PriceFeed]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
Node prints this warning only once per emitter and event name, so it appeared once per container start and then went quiet, which made it look harmless.
Reproducing it
Locally, with a load generator opening and closing short SSE connections:
npx autocannon -c 50 -d 120 http://localhost:3000/stream/EURUSD
After two minutes, process.memoryUsage().heapUsed had grown by about 90 MB and stayed there after
the load stopped and a manual GC ran (node --expose-gc, then global.gc()). Memory that survives
a full GC with no traffic is a leak, not a lazy collector.
What we ruled out
- The new JSON serialiser. The release also swapped in a faster serialiser. Reverting it on a canary made no difference to the slope.
- Buffered responses to slow clients. Backpressure can hold memory, but it goes away when the client disconnects. Ours did not.
- V8 heap sizing. Raising
--max-old-space-sizeonly moved the crash later, which is what a real leak does.
Root cause
We took two heap snapshots ten minutes apart under steady load and compared them in Chrome DevTools
(Comparison view). The biggest delta was thousands of (closure) objects, each retaining a
ServerResponse, and all of them were reachable from the _events.tick array of the single shared
PriceFeed emitter.
The SSE handler did this:
app.get('/stream/:pair', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
priceFeed.on('tick', (tick) => {
if (tick.pair === req.params.pair) res.write(`data: ${JSON.stringify(tick)}\n\n`);
});
});
Every connection added a listener. Nothing removed it when the client disconnected, so each closed
connection left a closure on the emitter that kept the req and res objects alive forever and
kept being called on every tick, writing to a destroyed socket.
What is happening under the hood
An EventEmitter stores listeners in a plain array per event name. A listener is a function, and a
function keeps alive every variable it closes over. A long-lived emitter (a module-level singleton,
a database client, process) therefore keeps alive everything every listener references, until
removeListener/off is called. Garbage collection cannot help: the objects are still reachable.
The default limit of 10 listeners per event is a heuristic. Exceeding it emits a warning, nothing
more. Calling setMaxListeners(0) silences the warning and leaves the leak intact, which is what a
previous team had done on a different emitter in this same codebase.
There is a second cost beyond memory. With 20,000 stale listeners, every price tick ran 20,000 callbacks, most of them writing to closed responses. Event loop delay rose from about 2 ms to 40 ms over the day, which our dashboards had misread as "busy hours".
Environment
| Component | Version / value |
|---|---|
| OS | Alpine 3.20 container, node:22-alpine |
| Runtime | Node.js 22.11.0 LTS |
| Framework | Express 4.21 |
| Transport | Server-Sent Events, 3,000 to 8,000 concurrent clients |
| Container limit | 1.5 GiB, restart on OOM |
| Price feed | One PriceFeed extends EventEmitter per process, ~40 ticks per second |
Minimal reproduction
import { EventEmitter } from 'node:events';
import http from 'node:http';
const feed = new EventEmitter();
setInterval(() => feed.emit('tick', { pair: 'EURUSD', bid: Math.random() }), 25);
http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
feed.on('tick', (t) => res.write(`data: ${JSON.stringify(t)}\n\n`)); // never removed
}).listen(3000);
setInterval(() => console.log('listeners', feed.listenerCount('tick')), 5000);
Open and close a few connections with curl -N localhost:3000 and the count only grows.
Who is affected
Any Node service that subscribes to a long-lived emitter inside a request, socket or job handler:
SSE and WebSocket fan-out, Redis pub/sub clients, process.on('SIGTERM') inside a factory, or
database pool events inside a per-request helper. Short-lived emitters (a single req) are fine,
because the emitter and its listeners die together.
The fix
Every subscription now has an owner and a matching unsubscribe tied to the connection lifecycle:
app.get('/stream/:pair', (req, res) => {
const pair = req.params.pair;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const onTick = (tick: Tick) => {
if (tick.pair !== pair) return;
// Respect backpressure: drop ticks for slow clients instead of buffering.
if (!res.writableNeedDrain) res.write(`data: ${JSON.stringify(tick)}\n\n`);
};
priceFeed.on('tick', onTick);
res.on('close', () => priceFeed.off('tick', onTick));
});
- priceFeed.on('tick', (tick) => { ... });
+ priceFeed.on('tick', onTick);
+ res.on('close', () => priceFeed.off('tick', onTick));
res emits close when the underlying connection closes, whether the client disconnected or the
server ended the response, so this covers both paths.
We also replaced "filter inside every listener" with per-pair channels, so each tick only reaches interested clients:
priceFeed.on(`tick:${pair}`, onTick);
res.on('close', () => priceFeed.off(`tick:${pair}`, onTick));
Fan-out to thousands of listeners on one event name is legitimate here, so we set an explicit, high limit on that emitter only, with a comment, rather than disabling the warning:
priceFeed.setMaxListeners(20_000); // one listener per SSE client, removed on close
Alternatives and trade-offs
AbortSignalbased cleanup. ForEventTargetAPIs,addEventListener(type, fn, { signal })removes the listener when the signal aborts; tie the controller tores.on('close'). Node'sevents.on(emitter, name, { signal })async iterator works the same way forEventEmitter.onceinstead ofon. Correct when you only need the first event (waiting forready), not for streams.- A dedicated pub/sub layer (Redis channels, NATS) with a subscription object that has an
explicit
unsubscribe(). More moving parts, but the API makes ownership obvious.
How to test the fix
A test opens and closes connections and asserts the listener count returns to zero:
import { once } from 'node:events';
import { test, expect } from 'vitest';
test('listeners are removed when clients disconnect', async () => {
const server = app.listen(0);
await once(server, 'listening');
const { port } = server.address() as { port: number };
for (let i = 0; i < 50; i++) {
const ctrl = new AbortController();
const res = await fetch(`http://127.0.0.1:${port}/stream/EURUSD`, { signal: ctrl.signal });
expect(res.status).toBe(200);
ctrl.abort();
}
await new Promise((r) => setTimeout(r, 100));
expect(priceFeed.listenerCount('tick:EURUSD')).toBe(0);
server.close();
});
For memory, we ran the autocannon scenario for 20 minutes against the fixed build with
node --heapsnapshot-signal=SIGUSR2 dist/server.js and compared snapshots taken at minute 5 and
minute 20. The retained size of ServerResponse objects matched the number of open connections.
Confirming in production
- Heap after 24 hours: 210 to 240 MB, flat, versus the old ramp to 1.4 GB.
- OOM restarts: zero in the 30 days after the release, versus 31 in the previous 30.
- Event loop delay p99 (from
perf_hooks.monitorEventLoopDelay) stayed at 3 to 5 ms all day. - A gauge we added,
pricefeed_listeners, tracks open SSE connections within a few percent.
setInterval(() => {
metrics.gauge('pricefeed_listeners', priceFeed.listenerCount('tick:EURUSD'), { pair: 'EURUSD' });
}, 10_000);
Regression guard
- We start the service with
--trace-warningsand our log pipeline raises an alert on anyMaxListenersExceededWarning, since a deliberate high limit means any warning is now real. - A lint rule flags
.on(calls on imported singletons inside route handlers unless the same function also calls.off(or.once(. - The listener-count test runs in CI.
Security implications
Before the fix, the leak was a denial-of-service lever: anyone could open and drop connections in a loop to inflate memory and crash a container faster. The fix removes that amplification. We also added a per-IP limit on concurrent SSE connections at the load balancer, which is worth having regardless.
Prevention checklist
- Every
on()against a long-lived emitter needs anoff()tied to a lifecycle event. - Never silence
MaxListenersExceededWarningglobally; raise limits per emitter with a reason. - Watch heap after GC and event loop delay, not just RSS.
- Take two heap snapshots under load and diff them before guessing.
- Load test with clients that disconnect, not just clients that stay connected.
References
- Node.js documentation: Events,
emitter.setMaxListeners()andremoveListener()— https://nodejs.org/docs/latest-v22.x/api/events.html - Node.js documentation:
http.ServerResponse'close'event — https://nodejs.org/docs/latest-v22.x/api/http.html - Node.js guide: Using heap snapshots — https://nodejs.org/en/learn/diagnostics/memory/using-heap-snapshot
- Chrome DevTools: Record heap snapshots — https://developer.chrome.com/docs/devtools/memory-problems/heap-snapshots
Add a label
Embed this post
<iframe src="https://hibug.me/nodejs-memory-leak-event-listeners-per-request" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.