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.
Step-by-Step Fix
- 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.
- Decide on compression. If you enable
perMessageDeflate, measure again. If the cost is too high, disable it or setserverNoContextTakeoverandclientNoContextTakeoverwith smallerserverMaxWindowBits. Verification: per-connection RSS with compression is within budget. - 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,bufferedAmountnever exceeds the hard limit. - 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.
- Cap message size. Set
maxPayloadto your protocol’s largest legitimate message. Verification: oversized frames close the connection with code 1009 instead of allocating. - Clean up on close. Remove listeners, registry entries and timers in the
closehandler. Verification: after all clients disconnect, heap returns to baseline.
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.
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.
Related
- Connections, Sockets and EventEmitter Memory — the parent topic
- HTTP Keep-Alive Agents and Socket Pool Memory — the outbound side
- MaxListenersExceededWarning and EventEmitter Leaks — listeners added per connection
- Async Iterators and Stream Backpressure — the same backpressure rules for streams