How to check connection limit of a server

Set a cap you control, then cross it. With server.maxConnections = 100 on the Node target below, 150 raw TCP connects left 100 held and exactly 50 closed by the server, and /stats reported dropped: 50. The live count comes from server.getConnections(), which the same endpoint prints.

Why check this

Four limits produce the sentence "the server refused the connection", and they have four owners. Run this before a capacity sign-off, and after a change that moves a service behind a new proxy.

The failure it prevents is a ticket filed against the wrong team: a test machine out of source ports reports errors that look like a saturated service while the service sits idle.

Generator and target share this machine's eight cores, so every number below describes the pair.

Prerequisites

// conn-server.mjs
import { createServer } from 'node:http';
const MAX = Number(process.env.MAX ?? 0);
const BACKLOG = Number(process.env.BACKLOG ?? 511);
let peak = 0, dropped = 0;
const server = createServer((req, res) => {
  res.writeHead(200, { 'content-type': 'application/json' });
  if (req.url === '/reset') { peak = 0; dropped = 0; return res.end('{"reset":true}'); }
  if (req.url === '/stats') {
    return server.getConnections((e, open) =>
      res.end(JSON.stringify({ open, peak, dropped, maxConnections: server.maxConnections })));
  }
  res.end('{"ok":true}');
});
server.on('connection', () => server.getConnections((e, n) => { if (n > peak) peak = n; }));
server.on('drop', () => { dropped += 1; });
if (MAX > 0) server.maxConnections = MAX;
server.listen(9741, '127.0.0.1', BACKLOG, () =>
  console.log(`listening 9741 maxConnections=${server.maxConnections} backlog=${BACKLOG}`));
// open-sockets.mjs <port> <count>
import net from 'node:net';
const port = Number(process.argv[2]), want = Number(process.argv[3]);
const tally = { requested: want, connected: 0, closedByPeer: 0, errors: {} };
for (let i = 0; i < want; i += 1) {
  const s = net.connect(port, '127.0.0.1');
  s.on('connect', () => { tally.connected += 1; });
  s.on('close', (hadError) => { if (!hadError) tally.closedByPeer += 1; });
  s.on('error', (e) => { tally.errors[e.code] = (tally.errors[e.code] ?? 0) + 1; });
}
setTimeout(() => { console.log(JSON.stringify(tally)); process.exit(0); }, 3000);
// backlog-server.mjs
import { createServer } from 'node:http';
const BACKLOG = Number(process.env.BACKLOG ?? 1);
const server = createServer((req, res) => res.end('{"ok":true}'));
server.listen(9742, '127.0.0.1', BACKLOG, () => {
  console.log(`listening 9742 backlog=${BACKLOG}`);
  const end = Date.now() + 8000;
  while (Date.now() < end) { /* event loop blocked: nothing is accepted */ }
  console.log('accepting again');
});
// port-pool.mjs <port> <poolStart> <poolSize> <rounds>
import net from 'node:net';
const [port, start, size, rounds] = process.argv.slice(2).map(Number);
const tally = { attempts: 0, connected: 0, errors: {} };
let i = 0;
const step = () => {
  if (i >= size * rounds) { console.log(JSON.stringify(tally)); process.exit(0); }
  const localPort = start + (i % size);
  i += 1; tally.attempts += 1;
  const s = net.connect({ port, host: '127.0.0.1', localPort, localAddress: '127.0.0.1' });
  s.on('connect', () => { tally.connected += 1; s.end(); setImmediate(step); });
  s.on('error', (e) => { tally.errors[e.code] = (tally.errors[e.code] ?? 0) + 1; setImmediate(step); });
};
step();

Steps

  1. Step 1.

    Confirm the port has no listener.

    netstat -ano | grep ":9741 " | grep -c LISTENING
    
    0

    Any other number means a process owns the port, and the last column of that line is its id.

  2. Step 2.

    Start the target with an application limit of 20.

    MAX=20 node conn-server.mjs & sleep 2 && curl -s http://127.0.0.1:9741/stats
    
    listening 9741 maxConnections=20 backlog=511
    {"open":1,"peak":1,"dropped":0,"maxConnections":20}

    open: 1 is the request asking the question. maxConnections appears only because it was set: unset it is undefined, and JSON.stringify drops the key.

  3. Step 3.

    Send load below the limit.

    curl -s http://127.0.0.1:9741/reset > /dev/null && npx autocannon@8 -c 10 -d 5 http://127.0.0.1:9741/ && curl -s http://127.0.0.1:9741/stats
    
    Running 5s test @ http://127.0.0.1:9741/
    10 connections
    
    ┌─────────┬──────┬──────┬───────┬──────┬─────────┬────────┬───────┐
    │ Stat    │ 2.5% │ 50%  │ 97.5% │ 99%  │ Avg     │ Stdev  │ Max   │
    ├─────────┼──────┼──────┼───────┼──────┼─────────┼────────┼───────┤
    │ Latency │ 0 ms │ 0 ms │ 0 ms  │ 0 ms │ 0.01 ms │ 0.1 ms │ 12 ms │
    └─────────┴──────┴──────┴───────┴──────┴─────────┴────────┴───────┘
    ┌───────────┬─────────┬─────────┬─────────┬─────────┬──────────┬──────────┬─────────┐
    │ Stat      │ 1%      │ 2.5%    │ 50%     │ 97.5%   │ Avg      │ Stdev    │ Min     │
    ├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼──────────┼─────────┤
    │ Req/Sec   │ 35 647  │ 35 647  │ 43 615  │ 44 927  │ 42 019,2 │ 3 321,39 │ 35 632  │
    └───────────┴─────────┴─────────┴─────────┴─────────┴──────────┴──────────┴─────────┘
    
    210k requests in 5.01s, 38.7 MB read
    {"open":1,"peak":10,"dropped":0,"maxConnections":20}

    peak: 10 matches -c 10, nothing was dropped, and throughput is 42 019,2 a second. That is the control for step 4.

  4. Step 4.

    Send load above the limit.

    curl -s http://127.0.0.1:9741/reset > /dev/null && npx autocannon@8 -c 50 -d 5 http://127.0.0.1:9741/ && curl -s http://127.0.0.1:9741/stats
    
    ┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬───────┐
    │ Stat    │ 2.5% │ 50%  │ 97.5% │ 99%   │ Avg     │ Stdev   │ Max   │
    ├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼───────┤
    │ Latency │ 5 ms │ 5 ms │ 9 ms  │ 11 ms │ 5.64 ms │ 1.47 ms │ 26 ms │
    └─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴───────┘
    ┌───────────┬────────┬────────┬────────┬────────┬─────────┬─────────┬────────┐
    │ Stat      │ 1%     │ 2.5%   │ 50%    │ 97.5%  │ Avg     │ Stdev   │ Min    │
    ├───────────┼────────┼────────┼────────┼────────┼─────────┼─────────┼────────┤
    │ Req/Sec   │ 2 557  │ 2 557  │ 3 483  │ 3 621  │ 3 292,2 │ 381,97  │ 2 556  │
    └───────────┴────────┴────────┴────────┴────────┴─────────┴─────────┴────────┘
    
    41k requests in 5.03s, 3.03 MB read
    25k errors (0 timeouts)
    {"open":1,"peak":20,"dropped":24690,"maxConnections":20}

    Five times the connections, one thirteenth of the throughput. The errors line is the only admission of trouble, and latency still reads 5.64 ms because only the responses that arrived are timed.

  5. Step 5.

    Name the failure. Open 50 sockets against the capped server, then 5 against a port with no listener.

    curl -s http://127.0.0.1:9741/reset > /dev/null && node open-sockets.mjs 9741 50 && node open-sockets.mjs 9749 5 && curl -s http://127.0.0.1:9741/stats
    
    {"requested":50,"connected":50,"closedByPeer":30,"errors":{}}
    {"requested":5,"connected":0,"closedByPeer":0,"errors":{"ECONNREFUSED":5}}
    {"open":1,"peak":20,"dropped":30,"maxConnections":20}

    All 50 connects succeeded. Node accepted each socket, counted it past maxConnections and destroyed it, so 30 arrive as a close from the peer and none as ECONNREFUSED. dropped: 30 is 50 minus the cap. A missing listener, the second line, is a different error.

  6. Step 6.

    Raise the limit and hit it at the new number.

    P=$(netstat -ano | grep "127.0.0.1:9741 " | grep LISTENING | awk '{print $NF}'); powershell -Command "Stop-Process -Id $P -Force"; sleep 1; MAX=100 node conn-server.mjs & sleep 2 && node open-sockets.mjs 9741 150 && curl -s http://127.0.0.1:9741/stats
    
    listening 9741 maxConnections=100 backlog=511
    {"requested":150,"connected":150,"closedByPeer":50,"errors":{}}
    {"open":1,"peak":100,"dropped":50,"maxConnections":100}

    The break moved with the setting, which identifies it as the cause. 150 minus 100 is 50, and the counter reads 50. The step 4 load repeated at this cap reported 42 300,8 a second and no error line.

  7. Step 7.

    Fill the accept queue instead. Start the blocked server at three backlog sizes, 50 sockets each.

    for b in 1 8 64; do BACKLOG=$b node backlog-server.mjs > /dev/null 2>&1 & sleep 1; echo -n "backlog=$b  "; node open-sockets.mjs 9742 50; sleep 6; P=$(netstat -ano | grep "127.0.0.1:9742 " | grep LISTENING | awk '{print $NF}'); powershell -Command "Stop-Process -Id $P -Force"; sleep 1; done
    
    backlog=1  {"requested":50,"connected":33,"closedByPeer":0,"errors":{"ECONNREFUSED":17}}
    backlog=8  {"requested":50,"connected":40,"closedByPeer":0,"errors":{"ECONNREFUSED":10}}
    backlog=64  {"requested":50,"connected":50,"closedByPeer":0,"errors":{}}

    A full accept queue answers ECONNREFUSED, and the count accepted rises with the backlog, which identifies the queue rather than guessing at it. Two Windows details sit there. A backlog of 1 accepted 33, so the value is a request and the stack keeps a floor of its own. And Windows resets the excess where Linux drops the packet.

  8. Step 8.

    Take the server limit away and look at the client. Compare the shell descriptor limit with what the process opens.

    P=$(netstat -ano | grep "127.0.0.1:9741 " | grep LISTENING | awk '{print $NF}'); powershell -Command "Stop-Process -Id $P -Force"; sleep 1; node conn-server.mjs > /dev/null 2>&1 & sleep 2; echo "ulimit -n = $(ulimit -n)"; node open-sockets.mjs 9741 4000; curl -s http://127.0.0.1:9741/stats
    
    ulimit -n = 3200
    {"requested":4000,"connected":4000,"closedByPeer":0,"errors":{}}
    {"open":1,"peak":4000,"dropped":0}

    4000 sockets held at once while ulimit -n reported 3200. That number belongs to the MSYS shell, which a native Windows process does not inherit.

  9. Step 9.

    Exhaust the source ports, the limit owned by the machine sending the traffic.

    netsh int ipv4 show dynamicport tcp && node port-pool.mjs 9741 51100 10 5
    
    Protocol tcp Dynamic Port Range
    ---------------------------------
    Start Port      : 49152
    Number of Ports : 16384
    
    {"attempts":50,"connected":10,"errors":{"EADDRINUSE":40}}

    Fifty attempts over ten source ports gave ten connections and 40 EADDRINUSE, because each closed socket holds its port in TIME_WAIT. The real pool is 16 384 ports from 49152 upward, and a client that opens them faster than they are released fails the same way. The server saw nothing.

  10. Step 10.

    Stop the target and confirm the port is clear.

    netstat -ano | grep "127.0.0.1:9741 " | grep LISTENING; powershell -Command "Stop-Process -Id 29096 -Force"; netstat -ano | grep ":9741 " | grep -c LISTENING
    
      TCP    127.0.0.1:9741         0.0.0.0:0              LISTENING       29096
    0

    Stop that id alone. Stopping every node.exe takes other servers with it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Connect succeeds, then the peer closes | An application limit such as maxConnections, or a proxy worker cap | Raise the setting and repeat. The break moves with it or the cause is elsewhere. | | ECONNREFUSED while the process is listening | The accept queue is full: backlog too small, or the loop is blocked | Raise the backlog, and check event loop delay before blaming the queue. | | ECONNREFUSED on every attempt | Nothing is listening on that port | Check the port and the bind address before any tuning. | | EADDRINUSE from the client | The generator ran out of source ports | Reuse connections, or move the generator to another machine. | | Throughput collapses, latency stays low | Refused sockets are not timed, only served ones are | Read the errors line, not the latency table. |

Common mistakes

Sign: Errors under load are reported as a server capacity limit, and the server's own counters show nothing.Cause: The client hit its own source port pool. This machine has 16 384 ephemeral ports and each closed socket holds one in TIME_WAIT, so a generator that does not reuse connections runs out while the target is idle. The proof is in the target: peak connections stayed at 10 during the run that produced 40 EADDRINUSE.
Sign: ulimit -n is raised before a load run on Windows and nothing changes.Cause: ulimit -n reported 3200 in Git Bash while the Node process held 4000 sockets in the same shell. The MSYS limit does not reach a native Windows process. There is no per-process descriptor limit to raise here, and the limits that do bite are the application cap, the accept queue and the source port pool.
Sign: A connection cap is diagnosed from the autocannon table and the table looks healthy.Cause: At 50 connections against a cap of 20, mean latency read 5.64 ms and the percentile row read 5 ms to 11 ms, because a destroyed socket produces no response to time. The only sign in the summary was the line 25k errors, and throughput at one thirteenth of the control run.
Sign: A backlog of 1 is set and dozens of connections are still accepted.Cause: The backlog argument to listen is a request, not a guarantee. With BACKLOG=1 and the accept loop blocked, Windows still accepted 33 of 50 connections here and refused the other 17. Compare two backlog values rather than trusting one.

What to check next

FAQ

How to check number of concurrent connections

server.getConnections(cb) returns the live count, and /stats above prints it. From outside the process: netstat -ano | grep "127.0.0.1:9741" | grep -c ESTABLISHED.

What is the difference between connection refused and connection reset?

Refused means no socket was accepted: nothing is listening, or the accept queue is full. Reset means the connection existed and the peer destroyed it, which is what maxConnections produces.

Does ulimit apply on Windows?

Not to a native Windows process. ulimit -n reported 3200 in Git Bash while Node held 4000 sockets. On Linux it is real and is the first thing to raise.

Verified

Verified by Maks Vernynode 22.23.2autocannon 8.0.0curl 8.21.0Windows 11 build 22631

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.

intermediate16 minpublished updated Maks Verny