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
- Node 22 or later, and
npx autocannon@8, which fetches autocannon on first use. - Two free ports, 9741 and 9742.
- The target,
conn-server.mjs.MAXsets server.maxConnections,BACKLOGthe second argument tolisten.
// 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, which holds sockets open and reports the error code of each failure.
// 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, which blocks its event loop, so nothing is accepted while the queue fills.
// 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, which binds its source port and exhausts a pool of ten.
// 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();
- Load goes to a host you own. Thousands of sockets aimed elsewhere is an attack.
Steps
- Step 1.
Confirm the port has no listener.
netstat -ano | grep ":9741 " | grep -c LISTENING0Any other number means a process owns the port, and the last column of that line is its id.
- 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/statslistening 9741 maxConnections=20 backlog=511 {"open":1,"peak":1,"dropped":0,"maxConnections":20}open: 1is the request asking the question.maxConnectionsappears only because it was set: unset it isundefined, andJSON.stringifydrops the key. - 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/statsRunning 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: 10matches-c 10, nothing was dropped, and throughput is 42 019,2 a second. That is the control for step 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
errorsline is the only admission of trouble, and latency still reads 5.64 ms because only the responses that arrived are timed. - 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
maxConnectionsand destroyed it, so 30 arrive as a close from the peer and none asECONNREFUSED.dropped: 30is 50 minus the cap. A missing listener, the second line, is a different error. - 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/statslistening 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.
- 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; donebacklog=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. - 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/statsulimit -n = 3200 {"requested":4000,"connected":4000,"closedByPeer":0,"errors":{}} {"open":1,"peak":4000,"dropped":0}4000 sockets held at once while
ulimit -nreported 3200. That number belongs to the MSYS shell, which a native Windows process does not inherit. - 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 5Protocol 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 inTIME_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. - 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 LISTENINGTCP 127.0.0.1:9741 0.0.0.0:0 LISTENING 29096 0Stop that id alone. Stopping every
node.exetakes 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
What to check next
- How to test concurrent users: below the cap, where latency rises instead of failing.
- How to stress test an api: where the service stops improving, long before it stops accepting.
- How to check requests per second an api can handle: the throughput figure in step 3.
- How to test API concurrency: parallel requests on one resource, where the limit is correctness.
- How to test load balancing: what a cap on one instance does to the others.
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.
Related on this site
intermediate16 minpublished updated Maks Verny