WebSocket Server Memory per Connection

Your WebSocket server handles 5,000 connections comfortably, but at 40,000 it is OOM-killed, and RSS is several times what heap snapshots account for. Or memory grows steadily through the day even though the number of connected clients is flat. This guide, part of Connections, Sockets and EventEmitter Memory in Node.js Server-Side Memory Management, shows how to measure the cost of one connection, which settings multiply it, and how to bound it with the ws library.

Symptom Root Cause Immediate Action Measurable Impact
RSS far above heap as connections grow Kernel buffers, TLS and compression contexts outside the V8 heap Measure RSS per connection; limit connections per instance Predictable capacity
Memory jumps when compression is enabled Per-message deflate keeps zlib contexts per connection Disable it, or use no-context-takeover and smaller windows Large drop in per-connection memory
One client causes a large spike Broadcasts queued for a slow client (bufferedAmount grows) Skip or disconnect clients above a buffer threshold Bounded buffer per connection
Memory grows with a flat client count Dead (half-open) connections never detected Ping/pong heartbeat with terminate() on timeout Connection count matches live clients
Large messages spike heap Default maxPayload accepts very large frames Set maxPayload to your protocol’s real maximum Bounded per-message allocation

Root Cause: Four Multipliers on a Small Base

A bare, idle WebSocket connection is cheap: a TCP socket with kernel buffers, a net.Socket and a WebSocket object with a frame parser — a few kilobytes to a few tens of kilobytes including TLS. Four things multiply that base, as the connections and sockets topic explains in general terms.

Application state is the first: the user record, subscriptions, rate limiter and timers you attach per connection. Compression is the second: with permessage-deflate, each connection can hold a zlib deflate and inflate context with sliding windows and internal state, often far larger than the connection itself — native memory that appears in RSS but not in heap snapshots. Buffered outbound data is the third: ws.send() never blocks; when a client reads slowly, messages queue in user space and ws.bufferedAmount grows, so a broadcast to thousands of clients can leave megabytes queued for the slowest ones. Dead connections are the fourth: a client that loses its network without closing leaves a half-open socket that the server keeps, with all its state, until something detects it.

The first multiplier grows with connection count; the second and third can dominate even at modest counts; the fourth makes memory grow over time with a flat number of real users. Each needs its own control.

What multiplies per-connection memory Illustrative per-connection cost: a bare TLS WebSocket is small. Adding typical application state makes it a few times larger. Enabling per-message deflate with context takeover adds zlib windows that can dwarf both. A slow client with broadcasts queued in bufferedAmount can hold far more than any of them. Exact values depend on your payloads and settings. bare TLS connection + application state + permessage-deflate contexts slow client, queued broadcasts illustrative scale — measure your own with the script below

Step-by-Step Fix

  1. Measure one connection’s cost. Open N idle connections from a load client (for example 1,000, then 5,000), force GC in a diagnostic run, and record heap used and RSS at each level. Verification: (RSS at 5,000 − RSS at 1,000) ÷ 4,000 gives RSS per connection; do the same for heap.
  2. Decide on compression. If you enable perMessageDeflate, measure again. If the cost is too high, disable it or set serverNoContextTakeover and clientNoContextTakeover with smaller serverMaxWindowBits. Verification: per-connection RSS with compression is within budget.
  3. Bound outbound buffers. Before sending, check ws.bufferedAmount; skip non-essential messages above a soft limit and terminate clients above a hard limit. Verification: in a test with throttled clients, bufferedAmount never exceeds the hard limit.
  4. Add a heartbeat. Ping every 30 seconds; terminate connections that did not answer the previous ping. Verification: connections from clients whose network was cut disappear within two intervals.
  5. Cap message size. Set maxPayload to your protocol’s largest legitimate message. Verification: oversized frames close the connection with code 1009 instead of allocating.
  6. Clean up on close. Remove listeners, registry entries and timers in the close handler. Verification: after all clients disconnect, heap returns to baseline.
Half-open connections over a day With a flat number of real users, a server without heartbeats accumulates half-open connections from clients that lost their network, so the open connection count and memory climb all day. With a 30 second ping and terminate on missed pong, the open count tracks real users. open no heartbeat: dead sockets pile up ping/terminate: tracks real users 24 hours, flat number of real users

Command and Code Reference

Use case: a bounded WebSocket server.

// ws-server.js — the ws library with explicit memory bounds
const { WebSocketServer } = require('ws');

const SOFT_LIMIT = 256 * 1024;   // skip non-essential messages above 256 KB queued
const HARD_LIMIT = 4 * 1024 * 1024; // disconnect above 4 MB queued

const wss = new WebSocketServer({
  port: 8080,
  maxPayload: 64 * 1024,         // largest legitimate inbound message: 64 KB
  perMessageDeflate: false,      // measure before enabling; see the compression variant below
});

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; }); // client answered the last ping
  ws.on('close', () => rooms.leaveAll(ws));    // release per-connection state
});

function broadcast(clients, msg, essential = false) {
  for (const ws of clients) {
    if (ws.bufferedAmount > HARD_LIMIT) { ws.terminate(); continue; } // cannot keep up
    if (!essential && ws.bufferedAmount > SOFT_LIMIT) continue;       // drop, don't queue
    ws.send(msg);
  }
}

// heartbeat: terminate connections that missed the previous ping
const interval = setInterval(() => {
  for (const ws of wss.clients) {
    if (!ws.isAlive) { ws.terminate(); continue; }
    ws.isAlive = false;
    ws.ping();
  }
}, 30_000);
wss.on('close', () => clearInterval(interval));

Use case: compression with bounded per-connection cost.

const wss = new WebSocketServer({
  port: 8080,
  perMessageDeflate: {
    serverNoContextTakeover: true,  // no sliding window kept between messages
    clientNoContextTakeover: true,
    serverMaxWindowBits: 10,        // smaller window than the default 15
    concurrencyLimit: 10,           // zlib jobs in parallel
    threshold: 1024,                // do not compress messages under 1 KB
  },
});

Use case: measuring per-connection memory.

# Server: expose GC for the diagnostic run only
node --expose-gc ws-server.js
# Load client opens N idle connections, then the server logs heapUsed and rss after global.gc()

Verification and Regression Prevention

Record RSS and heap per connection in a capacity note alongside the settings that produced them, and re-measure when you change compression, TLS configuration, Node version or per-connection state. Set a maximum connection count per instance from the RSS figure and the container limit, and reject or redirect new connections above it rather than letting the process grow unbounded.

In load tests, include throttled clients that read slowly and clients that vanish without closing. The slow clients should hit the soft limit and then the hard limit without raising memory for everyone else; the vanished clients should be terminated within two heartbeat intervals. After the test, disconnect all clients and confirm heap returns to baseline.

Load test with hostile clients Mix normal clients with throttled slow readers and clients that vanish without closing. Slow readers should hit the soft then hard buffer limits without raising memory for everyone else, vanished clients should be terminated within two heartbeat intervals, and after disconnecting all clients heap should return to baseline. Normal clients baseline per-connection cost Slow readers soft, then hard buffer limit Vanished clients terminated within 2 pings Disconnect all heap back to baseline

Edge Cases and Gotchas

Proxies with their own timeouts

Load balancers and proxies may close idle WebSocket connections after their own timeout. Keep your ping interval below it, or the proxy may cut connections your server still considers alive.

Browser tabs in the background

Browsers throttle timers in background tabs but still answer WebSocket pings at the protocol level. Heartbeats measure network liveness, not whether a user is looking at the page.

Broadcast serialisation

Serialise a broadcast message once and send the same string or Buffer to every client. Calling JSON.stringify per client allocates a copy per connection and multiplies garbage.

Rooms and subscription maps

Maps from room to set of sockets must drop the socket on close. A room map without cleanup is the most common reason memory grows with a flat client count.

Frequently Asked Questions

How many WebSocket connections can one Node.js process hold?

It depends on per-connection memory and CPU per message, not on a fixed number. Measure RSS per connection with your real state and settings, divide your container budget (minus a margin) by it, and set that as the instance’s connection limit.

Does permessage-deflate use a lot of memory?

It can. With context takeover, each connection keeps zlib windows for compression and decompression. Disable it, or use no-context-takeover and smaller window bits, and measure the difference before enabling it on a large deployment.

What is bufferedAmount?

The number of bytes queued by send() that have not yet been written to the socket. It grows when a client reads more slowly than you send. Checking it before sending is the only way to apply backpressure to broadcasts.

Why do I need a heartbeat if TCP has keep-alive?

TCP keep-alive defaults are measured in hours on many systems, and some networks drop keep-alive probes. An application-level ping every 30 seconds with a missed-pong timeout detects dead clients within a minute.

Does heap snapshot show WebSocket memory?

Partly. It shows JavaScript objects and queued Buffer objects, but zlib contexts, TLS state and kernel socket buffers are outside the heap. Compare RSS with heap used to see the rest.

Should I terminate or close slow clients?

close() starts a closing handshake that itself waits on the slow client; terminate() destroys the socket immediately. For clients above the hard buffer limit, terminate() releases memory at once.

Does Socket.IO change these numbers?

Socket.IO adds its own per-connection state — rooms, acknowledgement callbacks, buffered packets during reconnection and fallback transports — on top of the underlying WebSocket. The same controls apply: measure per-connection RSS, bound buffers, and make sure rooms and handlers are released when a socket disconnects.