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

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

  1. Step 1.

    Start the capped server.

    node cap-server.mjs > cap.log 2>&1 &
    
    capped server on 49320, maxConnections 50
    open sockets: 0
  2. 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; done
    
    target 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 ECONNRESET

    The first two rows track the request. The third and fourth stop at 50, the cap. Note what the surplus clients got: read ECONNRESET on the error event. The socket was destroyed before the upgrade response, so there is no close code and no 503 to read.

  3. 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; done
    
    target 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 ms

    Nothing 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.

  4. 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: 600

    600 rows for 300 sockets. On loopback both ends are on this machine, so each connection is counted twice. Divide by two before quoting a netstat number.

  5. Step 5.

    Test the setting that gets mistaken for a cap. Start a server with backlog set 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 200
    
    server on 49322, backlog 1, no maxConnections
    target 200: opened 200, failed 0, 100 ms

    All 200 opened. backlog is 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.

  6. Step 6.

    Read the ceiling the client machine imposes, which is the one a single-machine ramp hits first.

    netsh int ipv4 show dynamicport tcp
    
    Protocol tcp Dynamic Port Range
    ---------------------------------
    Start Port      : 49152
    Number of Ports : 16384

    Every 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

Sign: backlog is set low to limit connections, and the connections arrive anyway.Cause: backlog bounds the queue of connections waiting to be accepted, not the number open. A server listening with backlog 1 accepted 200 clients here, because the process accepts faster than the ramp connects. Use maxConnections on the server object for a cap.
Sign: The rejected clients produce no close code, so nothing shows up in the WebSocket logs.Cause: Over maxConnections the socket is destroyed before the HTTP upgrade completes. The client sees read ECONNRESET on its error event and there is no close frame, no status code and no handshake response. Instrument the error path, not only onclose.
Sign: netstat reports twice the number of connections the test opened.Cause: On loopback the client end and the server end are both on this machine and both match the port in a grep. Three hundred sockets produced 600 established rows. Count on the server process, or run the client from a second machine.
Sign: A single-machine ramp is reported as the server's capacity.Cause: The client needs one ephemeral port per connection, and this machine publishes 16384 of them. A ramp that stops near that number has measured the load generator. Split the clients across machines before treating a ceiling as the server's.

Thresholds

16384 ephemeral ports, from 49152, are available for outgoing connections on this machine

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.

Source: netsh int ipv4 show dynamicport tcp, Windows 11, 2026-09-12

What to check next

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.

intermediate12 minpublished updated Maks Verny