Measuring memory per WebSocket connection #
How many connections can one node hold? The question sounds like it needs a load test, but it mostly needs one number: the memory each connection costs. Teams guess it — “a few kilobytes” — and then discover in production that their pods are OOM-killed at a third of the planned density, because compression contexts, per-connection closures and kernel buffers were never in the estimate. Memory per connection is not a constant of the ws library; it depends on your options, your per-connection state and your traffic. This page builds a small harness that measures it for your server, splits it into heap, native and kernel parts, and shows which settings move it most.
Root cause #
A WebSocket connection’s memory lives in at least four places, and most tools show only one. The JavaScript heap holds the WebSocket object, its receiver and sender state, event listeners, and everything your code attaches — user objects, subscription sets, closures. Native memory outside the heap holds Buffer allocations and, if permessage-deflate is enabled, a zlib context per connection for each direction, which can be tens or hundreds of kilobytes depending on window size. The kernel holds socket structures and send/receive buffers, which do not appear in the process at all. And fragmentation makes RSS larger than the sum of the parts.
process.memoryUsage().heapUsed sees only the first. RSS sees the first three minus the kernel part, plus fragmentation. So the reliable measurement is a differential one: measure the process and the kernel with N connections open, subtract the baseline with none, divide by N — and do it at more than one N to confirm the relationship is linear.
Resolution #
The harness below starts your server in-process, opens connections in steps, and records heap, external, RSS and kernel TCP memory after each step with a forced garbage collection. Run it with node --expose-gc so collections are deterministic. It prints per-connection deltas you can put straight into a capacity plan.
// measure-conn-memory.ts — run: node --expose-gc --import tsx measure-conn-memory.ts
import { readFileSync } from 'node:fs';
import { WebSocketServer, WebSocket } from 'ws';
const STEPS = [0, 1_000, 5_000, 10_000]; // connection counts to sample
const PORT = 18080;
const SETTLE_MS = 2_000; // let buffers drain and timers fire
// Build the server exactly as production does — options change the answer.
const wss = new WebSocketServer({ port: PORT, perMessageDeflate: false });
wss.on('connection', (ws) => {
// Attach representative per-connection state here (subscriptions, user object …).
(ws as any).state = { subs: new Set(['room:1', 'room:2']), user: { id: 'u', roles: ['member'] } };
});
function kernelTcpBytes(): number {
// /proc/net/sockstat "TCP: inuse N orphan N tw N alloc N mem <pages>"
const tcp = readFileSync('/proc/net/sockstat', 'utf8').split('\n').find((l) => l.startsWith('TCP:'))!;
const pages = Number(tcp.split(/\s+/)[tcp.split(/\s+/).indexOf('mem') + 1]);
return pages * 4096;
}
function sample() {
global.gc?.(); // collect garbage so heapUsed reflects live objects
const m = process.memoryUsage();
return { heap: m.heapUsed, external: m.external + m.arrayBuffers, rss: m.rss, kernel: kernelTcpBytes() };
}
const clients: WebSocket[] = [];
async function openTo(n: number) {
while (clients.length < n) {
const batch = Array.from({ length: Math.min(500, n - clients.length) }, () =>
new Promise<void>((res) => { const c = new WebSocket(`ws://127.0.0.1:${PORT}`); c.on('open', () => res()); clients.push(c); }));
await Promise.all(batch);
}
await new Promise((r) => setTimeout(r, SETTLE_MS));
}
const base = sample();
for (const n of STEPS.slice(1)) {
await openTo(n);
const s = sample();
// NB: client sockets live in this process too, so divide by 2 for the server's share
// of heap/external/rss, or run clients from a separate process for cleaner numbers.
const per = (k: keyof typeof s) => Math.round((s[k] - base[k]) / n / 1024 * 10) / 10;
console.log(`${n} conns: heap ${per('heap')} KB native ${per('external')} KB rss ${per('rss')} KB kernel ${per('kernel')} KB`);
}
process.exit(0);
Running clients in the same process is convenient but double-counts user-space memory; for numbers you will plan with, run the client side from a second process or machine and measure only the server. Repeat the run with the options you are considering. The difference between perMessageDeflate: false and a default-configured deflate is usually the largest single change you can make, which is why permessage-deflate compression trade-offs recommends small windows or no compression for high-density servers.
Finding what your code attaches #
When the heap figure is higher than the library’s share, find out what your code holds per connection. Take two heap snapshots — one with 1,000 connections, one with 2,000 — and compare them in Chrome DevTools’ Memory panel using the “Comparison” view. Objects whose count grew by roughly 1,000 are per-connection allocations; sort by retained size to find the expensive ones. Common culprits are closures capturing large request objects (the upgrade req holds headers and the socket), duplicated user profiles instead of references to a shared cache, and per-connection timers.
# Take a heap snapshot from a running server without restarting it.
node --heapsnapshot-signal=SIGUSR2 dist/server.js &
kill -USR2 $! # writes Heap.<date>.heapsnapshot to the working directory
The same comparison technique finds leaks: if closing the extra 1,000 connections does not return the count to the lower snapshot, something retains closed sockets — see WebSocket rooms and channel subscriptions for the usual registry leak.
Verification #
Check that the measurement predicts reality. Take the per-connection total (heap + native + kernel), multiply by a planned connection count, add the process baseline, and compare with a load test at that count. Within about 15% is good; a larger gap means traffic is growing buffers beyond the idle measurement, and you should re-measure with representative message rates. Keep the harness in the repository and run it in CI when upgrading Node, ws or zlib-related options, since each can shift the figure.
Operational checklist #
FAQ #
How much memory does a WebSocket connection use in Node.js? #
With the ws library and compression disabled, an idle connection typically costs on the order of 10 KB of heap plus a few KB of kernel buffers. Application state often doubles that, and permessage-deflate with default settings can add hundreds of kilobytes of native memory. Measure your own configuration rather than trusting a number.
Why is RSS much larger than heapUsed? #
RSS includes the whole V8 heap (not just live objects), native allocations such as Buffers and zlib contexts, code, and allocator fragmentation. For WebSocket servers the native part is often the surprise, especially with compression enabled.
Does uWebSockets.js use less memory than ws? #
Generally yes: it keeps per-connection state in C++ with small fixed structures and avoids per-connection JavaScript objects unless you add them. Whether that matters depends on how much of your per-connection cost is library overhead versus your own state — benchmarking Node.js WebSocket servers compares them.
Should I set --max-old-space-size to the container limit? #
No. Leave room for native and kernel memory, which are outside the V8 heap but inside the container’s memory limit. A common split is heap at 60–70% of the container limit, adjusted using this measurement.
Related #
- Tuning Linux TCP for a Million WebSockets — the kernel part of the budget.
- Permessage-Deflate Compression Trade-Offs — the biggest native-memory lever.
- Capacity Planning for WebSocket Fleets — turning this number into a node count.
- Fixing Slow-Consumer Memory Growth — memory that grows with traffic, not connections.
Back to Connection Limits & OS Tuning.