Skip to content
Latchkey

Node.js "MaxListenersExceededWarning" - Fix EventEmitter Leaks in CI

Node warns when an EventEmitter accumulates more than 10 listeners for one event - usually a sign that listeners are added in a loop or per request without ever being removed.

What this error means

A noisy MaxListenersExceededWarning appears in logs (and a stack trace if --trace-warnings is on). In long test runs it can precede slow growth in memory. The warning itself does not crash, but it points at a real leak.

Node output
(node:1) MaxListenersExceededWarning: Possible EventEmitter memory leak
detected. 11 SIGTERM listeners added to [process]. Use emitter.setMaxListeners()
to increase limit

Diagnose it: the shell in CI is not your shell

Package scripts run under a different shell, a different PATH, and a non-interactive environment on a runner. Most scripts that fail only in CI are relying on something the login shell gave them locally: a tool on PATH, an environment variable from a dotfile, or a TTY.

Terminal
# what the script can actually see
npm run env | grep -E "^(PATH|NODE_ENV|CI)=" 

# is the binary on PATH for the script, not just for you?
npm exec -- which <tool> || echo "not resolvable from npm scripts"

# run the exact script with tracing
sh -x -c "$(node -p "require('./package.json').scripts.build")"

Common causes

Listeners added without removal

A function called repeatedly (per request, per test, in a loop) attaches a listener each time but never calls removeListener/off. The count climbs past the default limit of 10.

A genuinely high but bounded listener count

Some legitimate designs attach more than 10 listeners to one emitter on purpose. Here the warning is a false positive and the limit should be raised intentionally.

How to fix it

Find and remove the leaked listeners

Run with --trace-warnings to get the stack where the listener is added, then ensure each on() has a matching off() (or use once()).

Terminal / app.mjs
node --trace-warnings app.mjs
# in code: remove what you add
const onTerm = () => shutdown();
process.on('SIGTERM', onTerm);
// later
process.off('SIGTERM', onTerm);

Raise the limit only when the count is legitimate

If many listeners are correct by design, raise the threshold on that specific emitter - not globally as a way to silence a real leak.

app.mjs
emitter.setMaxListeners(50);

Make failures fail the job

A multi-command script can report success while a middle command failed, which produces the worst kind of CI result: a green build that shipped something broken.

.github/workflows/ci.yml
# pipefail is NOT set by default in every runner shell
- name: Build
  shell: bash
  run: |
    set -euo pipefail
    npm run build | tee build.log

How to prevent it

  • Pair every on() with an off(), or use once() for one-shot handlers.
  • Run with --trace-warnings in CI so the leak source is visible.
  • Only call setMaxListeners when a high count is intentional and bounded.

Frequently asked questions

What causes Node.js "MaxListenersExceededWarning"?
There are 2 common causes: listeners added without removal and a genuinely high but bounded listener count. A function called repeatedly (per request, per test, in a loop) attaches a listener each time but never calls removeListener/off.
How do I fix Node.js "MaxListenersExceededWarning"?
There are 2 fixes depending on which cause you have: find and remove the leaked listeners and raise the limit only when the count is legitimate. Work through them in order, since the first is the most common.
What does Node.js "MaxListenersExceededWarning" actually mean?
A noisy MaxListenersExceededWarning appears in logs (and a stack trace if --trace-warnings is on).
How do I stop Node.js "MaxListenersExceededWarning" happening again?
Pair every on() with an off(), or use once() for one-shot handlers. The prevention section lists 3 changes that keep it from recurring.

Related guides

References

Not every red build is your code. Latchkey repairs the ones that are not, on the runner. Start free → 30-day trial · No credit card