Websocket max connections
Open sockets in growing batches until the accepted count stops rising. Against a server with maxConnections set to 50, batches of 10, 40, 80 and 200 opened 10, 40, 50 and 50, and the surplus failed with ECONNRESET. An uncapped server kept accepting up to 2000.
Why check this
Run this before a launch that multiplies the number of open sockets: a dashboard that connects on page load, a mobile client that reconnects on every resume, a second browser tab with a socket of its own. Run it again after the server moves behind a proxy, which caps connections too.
The failure it prevents is a ceiling nobody knew existed. At the cap, new clients are refused at the TCP layer, before any WebSocket handshake, so there is no close code and no application log line to find later. Monitoring shows healthy sockets, and a support queue that says the page will not load.
Prerequisites
- Node 22 and
ws8 in an empty directory:npm i ws@8. - Three free ports.
netstat -ano | grep 49320prints nothing when 49320 is free, and the same for 49317 and 49322. - Everything here runs against a server on this machine, on 2026-09-12. A ceiling found this way belongs to one process on one host, with one client machine in front of it. It is not a capacity figure.
Save this as cap-server.mjs. The cap lives on the HTTP server under ws, which is where a Node service usually gets one.
// node cap-server.mjs listens on ws://127.0.0.1:49320
import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';
const server = createServer();
server.maxConnections = 50; // the cap under test
const wss = new WebSocketServer({ server });
let open = 0;
wss.on('connection', (ws) => {
open += 1;
ws.on('close', () => { open -= 1; });
});
setInterval(() => console.log(`open sockets: ${open}`), 1000).unref();
server.listen(49320, () => console.log('capped server on 49320, maxConnections 50'));
Save this as echo-server.mjs. Step 3 needs a server with no cap on it at all.
// node echo-server.mjs listens on ws://127.0.0.1:49317
import { WebSocketServer } from 'ws';
const PORT = 49317;
const MAX_PAYLOAD = 8192;
const wss = new WebSocketServer({ port: PORT, maxPayload: MAX_PAYLOAD });
let open = 0;
wss.on('connection', (ws) => {
open += 1;
ws.on('message', (data, isBinary) => ws.send(data, { binary: isBinary }));
ws.on('error', (err) => console.log(`server socket error: ${err.message}`));
ws.on('close', (code) => {
open -= 1;
console.log(`closed ${code}, open ${open}`);
});
});
wss.on('listening', () =>
console.log(`echo listening on ${PORT}, maxPayload ${MAX_PAYLOAD} bytes`));
Save this as ramp.mjs. It opens a batch, counts what opened against what failed, and groups the failures by message.
// node ramp.mjs <url> <count>
import WebSocket from 'ws';
const url = process.argv[2];
const n = Number(process.argv[3]);
const sockets = [];
let opened = 0;
let failed = 0;
const reasons = new Map();
const t0 = Date.now();
function done() {
if (opened + failed < n) return;
console.log(`target ${n}: opened ${opened}, failed ${failed}, ${Date.now() - t0} ms`);
for (const [k, v] of reasons) console.log(` ${v} x ${k}`);
for (const s of sockets) s.close(1000);
setTimeout(() => process.exit(0), 300);
}
for (let i = 0; i < n; i += 1) {
const ws = new WebSocket(url);
sockets.push(ws);
ws.on('open', () => { opened += 1; done(); });
ws.on('error', (e) => {
failed += 1;
reasons.set(e.message, (reasons.get(e.message) ?? 0) + 1);
done();
});
}
Steps
- Step 1.
Start the capped server.
node cap-server.mjs > cap.log 2>&1 &capped server on 49320, maxConnections 50 open sockets: 0 - Step 2.
Ramp the batch size until the accepted count stops following it.
for n in 10 40 80 200; do node ramp.mjs ws://127.0.0.1:49320 $n; donetarget 10: opened 10, failed 0, 57 ms target 40: opened 40, failed 0, 59 ms target 80: opened 50, failed 30, 84 ms 30 x read ECONNRESET target 200: opened 50, failed 150, 183 ms 150 x read ECONNRESETThe first two rows track the request. The third and fourth stop at 50, the cap. Note what the surplus clients got:
read ECONNRESETon theerrorevent. The socket was destroyed before the upgrade response, so there is no close code and no 503 to read. - Step 3.
Run the same ramp against a server with no cap, to see what the absence of a limit looks like.
for n in 200 500 1000 2000; do node ramp.mjs ws://127.0.0.1:49317 $n; donetarget 200: opened 200, failed 0, 172 ms target 500: opened 500, failed 0, 297 ms target 1000: opened 1000, failed 0, 745 ms target 2000: opened 2000, failed 0, 1677 msNothing flattens. The time per batch rises with the batch and no ceiling appears in this range, so this run found no limit and none should be reported.
- Step 4.
Hold a batch open and count the connections from outside the process.
// node hold.mjs <url> <count> import WebSocket from 'ws'; const n = Number(process.argv[3]); let opened = 0; for (let i = 0; i < n; i += 1) { const ws = new WebSocket(process.argv[2]); ws.on('open', () => { opened += 1; if (opened === n) console.log(`${opened} sockets open, holding 5 s`); }); } setTimeout(() => process.exit(0), 5000);node hold.mjs ws://127.0.0.1:49317 300 & sleep 3; echo "established on 49317: $(netstat -ano | grep 49317 | grep ESTABLISHED | wc -l)"300 sockets open, holding 5 s established on 49317: 600600 rows for 300 sockets. On loopback both ends are on this machine, so each connection is counted twice. Divide by two before quoting a
netstatnumber. - Step 5.
Test the setting that gets mistaken for a cap. Start a server with
backlogset to 1 and ramp 200 clients at it.// node backlog-server.mjs listens on ws://127.0.0.1:49322 import { createServer } from 'node:http'; import { WebSocketServer } from 'ws'; const server = createServer(); new WebSocketServer({ server }); server.listen({ port: 49322, backlog: 1 }, () => console.log('server on 49322, backlog 1, no maxConnections'));node backlog-server.mjs & sleep 3; node ramp.mjs ws://127.0.0.1:49322 200server on 49322, backlog 1, no maxConnections target 200: opened 200, failed 0, 100 msAll 200 opened.
backlogis the length of the queue of connections waiting to be accepted, not the number that may be open. A process that accepts promptly drains that queue, so its length never becomes the limit. - Step 6.
Read the ceiling the client machine imposes, which is the one a single-machine ramp hits first.
netsh int ipv4 show dynamicport tcpProtocol tcp Dynamic Port Range --------------------------------- Start Port : 49152 Number of Ports : 16384Every outgoing connection holds one of these ports, so one client machine cannot keep much more than 16384 sockets against one target, whatever the server allows. The matching server-side limit on Linux is the process descriptor cap,
ulimit -n, read on that host. Windows has no equivalent number to quote.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Opened count equals the batch at every size | No limit was reached in that range | Raise the batch, or report that no ceiling was found. |
| Opened count flat while the batch grows | The cap is that flat number | Record it, then find which setting produces it. |
| read ECONNRESET on the failures | The connection was dropped before the upgrade | Look at the TCP layer and the proxy, not at close codes. |
| Failures rise while the flat number holds | The server is refusing, not degrading | A cap is working. Check that the client shows the user something. |
| netstat count double the sockets you opened | Both ends are on this machine | Divide by two, or move the client to another host. |
Common mistakes
Thresholds
That is the ceiling of the client side of any single-machine ramp. The server's own ceiling is a different number: maxConnections if the application sets one, otherwise memory and, on Linux, the process descriptor limit.
What to check next
- How to check connection limit of a server: the same ramp against an HTTP server, where the queue and the worker count differ.
- Websocket connection timeout: idle sockets that are never closed are what fills a cap in production.
- How to check websocket ping pong: how a server notices that a held connection is dead and frees the slot.
- How to test websocket connection: the single-connection handshake this ramp repeats.
FAQ
How many websocket connections can one server handle?
There is no fixed number. Whichever limit binds first decides: an application cap, memory per connection, the process descriptor limit, or the client's own ephemeral ports. Ramp to find it, as in step 2.
What happens to clients above the limit?
With maxConnections they are dropped at the TCP layer. The 150 surplus clients in step 2 reported read ECONNRESET on the error event, with no close code and no HTTP response to read.
Does backlog limit the number of connections?
No. It bounds the queue of connections not yet accepted. A server listening with backlog 1 accepted 200 clients in step 5, because it accepts continuously and the queue drains as fast as it fills.
Why does netstat show more connections than I opened?
On loopback both ends live on this machine, so each connection matches the port twice. Three hundred sockets produced 600 established rows in step 4.
How do I test a limit above ten thousand?
Not from one machine. The client runs out of ephemeral ports first, 16384 here. Use several load generator hosts, or read the server's descriptor usage during a smaller ramp.
Verified
Verified by Maks VernyNode 22.23.2ws 8.21.3netsh Windows 11
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny