Connections, Sockets and EventEmitter Memory
A Node.js server’s memory is not only its heap of application objects. Every open connection holds a socket with kernel buffers, a JavaScript Socket object, parser state, stream buffers, and whatever the application attaches to it — session data, subscriptions, listeners. Every EventEmitter holds its listener functions and everything those closures capture until the listeners are removed. These costs are small per item and multiply with concurrency, which is why they surface as memory that grows with traffic, with connected clients, or with uptime. This topic, part of Node.js Server-Side Memory Management, is for engineers running WebSocket servers, HTTP services that call other services, and any long-lived process built on events. It covers per-connection memory in WebSocket servers, keep-alive agents and socket pools, and MaxListenersExceededWarning and EventEmitter leaks.
Conceptual Grounding
What a connection costs
An accepted TCP connection has costs in three places. In the kernel, each socket has send and receive buffers whose sizes adapt to traffic; these count toward the container’s memory but not toward the V8 heap. In libuv and Node’s C++ layer, each socket has a handle and, for TLS, an OpenSSL session with its own buffers — native memory visible in RSS. In JavaScript, each connection has a net.Socket (or tls.TLSSocket) object, readable and writable stream state including any buffered chunks, an HTTP parser for HTTP connections, and the objects your code creates per connection: a user record, a subscription list, a rate limiter, timers, and listener closures.
An idle connection with no buffered data typically costs from a few kilobytes to a few tens of kilobytes in total, with TLS and application state at the upper end. That is negligible for a hundred connections and significant for a hundred thousand. The dominant term is almost always what the application attaches, plus buffered data on connections whose peer is not reading — a slow consumer can hold megabytes in a single socket’s write buffer if backpressure is ignored.
Server connections versus client pools
On the server side, connection count is set by clients: every browser tab with an open WebSocket, every keep-alive HTTP connection from a load balancer. Memory scales with concurrent connections and with how long idle connections are kept. On the client side — your service calling databases, caches and HTTP APIs — connection count is set by your pools and agents. An http.Agent with keepAlive: true keeps sockets open for reuse; the maxSockets and maxFreeSockets options and the free-socket timeout decide how many stay alive. An agent created per request instead of shared defeats pooling and leaves sockets open until they time out.
EventEmitters and listener lifetime
EventEmitter stores listeners in arrays keyed by event name. A listener stays registered until off/removeListener is called, once fires, or the emitter itself becomes unreachable. The emitter keeps each listener function alive, and each function keeps its closure scope alive — often the object that registered it. When a short-lived object subscribes to a long-lived emitter (a shared socket, a process-wide event bus, process itself) and never unsubscribes, the long-lived emitter keeps every short-lived subscriber alive. Node warns with MaxListenersExceededWarning when more than 10 listeners are added for one event on one emitter, which is often the first visible sign.
Why these leaks look like traffic
Connection and listener memory grows with activity, so it is easy to mistake for normal load. The distinguishing test is what happens when activity stops. If memory returns to baseline after clients disconnect and a garbage collection runs, the growth was working set. If it stays up, something outlives the connections: state keyed by connection ID in a module-level map, listeners on a shared emitter, timers that were not cleared, or sockets that were never closed. Heap snapshots taken after traffic stops show the survivors directly, with retainer paths through _events arrays for listener leaks or through a Map for registries — the same technique used in the three-snapshot technique for isolating leaks.
Diagnostic Workflow
- Correlate memory with connection counts. Export the number of open server connections (
server.getConnections(), or your WebSocket server’s client count) and outbound pool sizes alongside heap used and RSS. Expected output: a dashboard where memory and connection curves can be compared. Metric: memory per open connection over time. - Stop traffic and collect. In staging, run load, disconnect all clients, wait for idle timeouts, and force a collection in a diagnostic run. Expected output: heap after disconnect. Metric: heap after disconnect versus the pre-load baseline; a gap of more than a few megabytes means survivors.
- Snapshot the survivors. Take a heap snapshot after the disconnect and filter by
Socket,TLSSocket,IncomingMessage, your connection class names andTimeout. Expected output: instance counts that should be zero. Metric: surviving connection objects and their retained size. - Follow retainers. For each survivor, open the retainer path. Paths through
_eventspoint to listeners on a longer-lived emitter; paths through aMappoint to a registry without cleanup; paths throughTimeoutpoint to uncleared timers. Expected output: the owning emitter or registry. Metric: one named owner per survivor type. - Check listener counts. Log
emitter.listenerCount(event)for shared emitters before and after load, and run with--trace-warningsto get stack traces forMaxListenersExceededWarning. Expected output: listener counts that return to baseline. Metric: listeners per event after traffic stops. - Inspect outbound pools. For HTTP agents, log the number of sockets and free sockets per origin; for database and cache clients, the pool’s total and idle counts. Expected output: pool sizes within configured limits. Metric: open outbound sockets versus
maxSocketsand expected concurrency.
Code Patterns & Signatures
Per-connection state cleaned up on close. Tie everything a connection creates to its close event, and prefer AbortSignal so one call removes many listeners.
// server.js — every per-connection resource has a teardown on close
const { WebSocketServer } = require('ws');
const bus = require('./bus'); // process-wide EventEmitter
const wss = new WebSocketServer({ port: 8080, maxPayload: 1 << 20 }); // cap message size at 1 MB
wss.on('connection', (ws) => {
const ac = new AbortController(); // one switch for all listeners below
const onPrice = (p) => { if (ws.bufferedAmount < 1 << 20) ws.send(JSON.stringify(p)); }; // skip slow clients
bus.on('price', onPrice); // long-lived emitter: must be removed
ac.signal.addEventListener('abort', () => bus.off('price', onPrice));
const ping = setInterval(() => ws.ping(), 30_000); // timer tied to this connection
ws.on('close', () => { clearInterval(ping); ac.abort(); }); // release everything on close
});
A shared keep-alive agent with bounded pools. One agent per upstream, created once at startup, not per request.
// upstream.js — pooled sockets with explicit bounds
const http = require('node:http');
const agent = new http.Agent({
keepAlive: true,
maxSockets: 50, // concurrent sockets per origin
maxFreeSockets: 10, // idle sockets kept for reuse
timeout: 60_000, // socket inactivity timeout (ms)
});
module.exports = (path) => new Promise((resolve, reject) => {
const req = http.get({ host: 'inventory.internal', path, agent }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (c) => { body += c; });
res.on('end', () => resolve(body)); // body fully consumed: socket returns to the pool
});
req.on('error', reject);
});
Listener counts in diagnostics.
// log listener counts on shared emitters every minute in diagnostic builds
const { getEventListeners } = require('node:events');
setInterval(() => {
console.log('price listeners', bus.listenerCount('price'),
'process exit listeners', getEventListeners(process, 'exit').length);
}, 60_000).unref(); // unref: never keep the process alive just for diagnostics
# Get a stack trace for every MaxListenersExceededWarning
node --trace-warnings server.js
Symptom-to-Fix Reference
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Memory grows with connected clients and stays up after they leave | Per-connection state in a registry or on a shared emitter, never removed on close |
Remove listeners and registry entries in the close handler |
Heap returns to baseline after disconnect |
MaxListenersExceededWarning in logs |
Listener added per request or connection to a long-lived emitter | Find the stack with --trace-warnings; add off or use once/AbortSignal |
Listener count constant under load |
| RSS far above heap on a WebSocket server | Kernel and TLS buffers, plus large write buffers for slow clients | Check bufferedAmount; drop or disconnect slow consumers; cap maxPayload |
Bounded per-connection memory |
| Outbound socket count grows with traffic | New http.Agent per request, or response bodies not consumed |
Share one agent; always consume or destroy responses | Pool at or below maxSockets |
| Idle memory high hours after a traffic peak | maxFreeSockets too high or no free-socket timeout |
Lower maxFreeSockets; set timeout |
Pool shrinks after peaks |
| Timers keep firing for closed connections | setInterval per connection not cleared |
Clear in close; tie to an AbortSignal |
Timeout count tracks connections |
Edge Cases & Gotchas
setMaxListeners hides the symptom
Raising the limit with emitter.setMaxListeners(0) silences the warning without fixing the leak. Raise it only for emitters that legitimately have many listeners — a shared broadcast emitter with one listener per connected client, for example — and then monitor its listener count against the connection count.
close versus end versus error
Sockets can end in several ways: a clean end, an error followed by close, or a timeout with destroy. Put cleanup in close, which fires after all of them, rather than in end or error alone, or some paths will skip it.
Half-open connections
A client that disappears without closing (a phone losing signal) leaves a server socket open until TCP keep-alive or your application’s heartbeat detects it. Without a heartbeat and timeout, these connections and their state accumulate. Use WebSocket pings with a timeout, or socket.setKeepAlive and server.keepAliveTimeout for HTTP.
Load balancer keep-alive
Load balancers hold keep-alive connections to every backend. If server.keepAliveTimeout is shorter than the balancer’s idle timeout, you get resets; if much longer, idle connections pile up. Align them deliberately.
Streams piped into long-lived emitters
pipe and pipeline add listeners to both streams. A long-lived destination piped from many short-lived sources keeps listeners unless each pipe is torn down. Use pipeline with error handling so failed transfers clean up.
Listeners added by libraries
Libraries often add listeners to process (exit, SIGTERM, uncaughtException) when initialised. Initialising a library per request multiplies those listeners. Initialise once at startup.
How Connection Memory Interacts with Container Limits
Most per-connection memory outside your own state is not in the V8 heap. Kernel socket buffers and TLS sessions count toward the container’s memory limit through RSS but do not show up in heap snapshots or heapUsed. A WebSocket server with a modest heap can be OOM-killed purely from connection count and buffered data. Budget with RSS per connection measured under realistic traffic, as described in RSS growing with a flat heap, and set a connection limit per instance that leaves headroom — then scale horizontally rather than letting one process accept unbounded connections.
Relationship to Stream Backpressure
Buffered data on connections is where connection memory meets stream memory. A WebSocket send or a socket write to a slow client queues data in user space when the kernel buffer is full; bufferedAmount or writableLength shows how much. Ignoring it turns one slow client into megabytes of retained data. The same backpressure rules that apply to streams, covered in Node.js stream backpressure and memory growth, apply to every socket: check the buffer, pause or drop, and disconnect consumers that cannot keep up.
Monitoring Connection Memory in Production
Three numbers explain most connection-related memory: open connections (inbound and per outbound origin), listener counts on shared emitters, and RSS per connection. Export them alongside heap used and RSS, on the same dashboard, so a change in one can be read against the others. Memory rising with connection count at a constant ratio is capacity; memory rising while connections are flat is a leak; RSS rising faster than heap points to native buffers, TLS or compression state rather than JavaScript objects.
Alert on ratios rather than absolutes. A fixed RSS threshold fires on every traffic peak; memory per open connection, or listener count divided by in-flight requests, stays stable under healthy load and moves only when something is retained. The growth-slope approach in alerting on memory leaks with growth slope works well for the idle-period baseline: if the memory floor after each quiet period keeps rising, something outlives its connections.
Finally, set explicit limits and make them visible. A maximum number of inbound connections per instance, maxSockets per upstream, maxPayload for messages and buffer thresholds for slow consumers all turn unbounded growth into a bounded, observable condition. When a limit is hit, log it with enough context — origin, client type, buffer size — to tell a traffic change from a regression.
Frequently Asked Questions
How much memory does an idle connection use in Node.js?
Typically a few kilobytes to a few tens of kilobytes across the kernel, native layer and JavaScript objects, with TLS at the upper end. Application state attached to each connection usually dominates, so measure RSS and heap per connection under your own workload.
What does MaxListenersExceededWarning mean?
More than the default limit of 10 listeners were added for one event on one emitter. It is not an error, but it often signals that listeners are added repeatedly — per request or per connection — without being removed, which leaks the listeners and everything their closures capture.
Should I call setMaxListeners to remove the warning?
Only when the emitter legitimately needs many listeners, and then with a specific number rather than zero. Otherwise find the code that adds listeners without removing them; --trace-warnings prints the stack that triggered the warning.
Why does memory stay high after all WebSocket clients disconnect?
Something outlives the connections: listeners on a shared emitter, entries in a module-level map keyed by connection, uncleared timers, or half-open sockets that have not timed out. A heap snapshot after disconnect shows the survivors and their retainers.
Does keepAlive on an HTTP agent increase memory?
Slightly: idle sockets are kept for reuse, each with its buffers. The saving in connection setup usually outweighs it. Bound the pool with maxSockets and maxFreeSockets and set an idle timeout so the pool shrinks after peaks.
How do I find which emitter is leaking listeners?
Take a heap snapshot after load and look for retainer paths through _events; the object holding that _events property is the emitter. In code, log listenerCount for suspect emitters over time, and use getEventListeners from node:events for emitters and EventTargets alike.
Are EventTarget and AbortSignal listeners different?
They are stored differently but follow the same rule: a listener lives as long as it is registered and its target is reachable. Registering with an AbortSignal option lets you remove many listeners with one abort() call, which is the simplest way to tie them to a connection’s lifetime.
Do database connection pools have the same issues?
Yes. Pools keep connections and their buffers open; creating a pool per request, or never releasing checked-out connections, grows memory and connection counts. Create pools once, release connections in finally, and monitor total and idle counts.
Does closing a socket free everything attached to it?
Closing releases the kernel buffers and native handle, and the Socket object becomes collectable once nothing references it. Anything else your code attached — registry entries, listeners on other emitters, timers — survives until you remove it explicitly, which is why cleanup belongs in the close handler.
Related
- WebSocket Server Memory per Connection — measuring and bounding connection cost
- HTTP Keep-Alive Agents and Socket Pool Memory — outbound pools and agents
- MaxListenersExceededWarning and EventEmitter Leaks — finding and fixing listener leaks
- Node.js Stream Backpressure and Memory Growth — buffered data on slow consumers
- Node.js Server-Side Memory Management — the section overview