Programming Languages Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · December 05, 2024 · 8 min read · 5 Views
Banner: Node.js memory leak from event listeners added per request on a shared EventEmitter

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-size only 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

  • AbortSignal based cleanup. For EventTarget APIs, addEventListener(type, fn, { signal }) removes the listener when the signal aborts; tie the controller to res.on('close'). Node's events.on(emitter, name, { signal }) async iterator works the same way for EventEmitter.
  • once instead of on. Correct when you only need the first event (waiting for ready), 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-warnings and our log pipeline raises an alert on any MaxListenersExceededWarning, 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 an off() tied to a lifecycle event.
  • Never silence MaxListenersExceededWarning globally; 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

Add a label

#nodejs #memory-leak #eventemitter #heap-snapshot #server-sent-events
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.