practices
guide
09 Oct 2026
Eight HTTP clients against a server that never answers
A socket that accepts your connection and then says nothing. Two of the eight clients gave up on their own. The other six were still holding the line sixteen minutes later.
By Purvansh Parmar
Checked 2026-10-21
Read 8 min
Entries 8
The failure is not a connection refused. It is a connection accepted, and then silence. Your client is holding an open socket to a process that will never speak again, and the question is how long it intends to do that. For six of the eight clients tested here the answer is: longer than you are prepared to wait. No error, no retry, no log line. One worker, parked on a socket, until the pod is restarted or someone notices that the queue stopped moving.
How this was checkedWrote a server that accepts a TCP connection, reads the request, and then never sends a byte, plus a second one that sends complete headers and stalls mid body. Pointed eight HTTP clients at both and timed how long each waited. Node 22's global fetch and Bun both gave up at almost exactly 300 seconds. curl, wget, urllib, requests, node http and node-fetch were all still waiting when the harness killed them, and the header variant changed nothing for any of them.
What I compared on
- time until the client gave up on its own, with no timeout configured
- whether complete response headers changed the behaviour
- what the error says when there finally is one
The fetch built into Node since 18, which is undici underneath whether you installed it or not.
Wins
It is the only Node option with a real default: undici sets headersTimeout and bodyTimeout to 300 seconds each, so a stuck server eventually produces an exception instead of a parked worker.
Loses
Five minutes is far longer than any request you meant to make, and the undici documentation does not promise that the global fetch inherits these values, so the only way to know what your runtime does is to measure it.
300.9 s300 s headersTimeout300 s bodyTimeout
Bun's own fetch implementation, not undici, reached through the identical script.
Wins
It fails with a plain TimeoutError rather than a stack trace from a bundled dependency, and it matches Node's effective default closely enough that moving code between the two does not change this behaviour.
Loses
The same five minute problem: long enough that every upstream caller has already timed out, and the limit is not documented anywhere near the fetch API reference.
300.1 scode 23DOMException TimeoutError
The command every health check and container entrypoint shells out to, invoked with no flags beyond the URL.
Wins
It is the easiest of all of them to fix, because the flags already exist and are one word: --max-time caps the whole transfer and -m is the short form everyone can remember.
Loses
The only timeout curl sets for you is --connect-timeout at 300 seconds, and that covers the connect phase, which succeeded instantly here. The phase that hangs has no default limit at all.
960 s and counting300 s connect timeout0 s transfer timeout
The other thing container images shell out to, run as wget -qO- against the same URL.
Wins
It is the only command line client here that documents a read timeout as a default at all, and --read-timeout is measured as idle time, which is the right definition for a stalled transfer.
Loses
The documented default is 900 seconds, which is fifteen minutes of a build or a cron job doing nothing, and the manual buries it among the other three timeout flags.
960 s and counting900 s documented read timeout
requests.get(url) with no second argument, which is how the call is written almost everywhere.
Wins
The fix is a single keyword argument and the library supports a connect and read pair, so requests.get(url, timeout=(3.05, 15)) separates the two phases properly.
Loses
There is no default and no warning. The documentation says to use the parameter in nearly all production code, which is an admission that the default is wrong for nearly all production code.
960 s and countingtimeout=None by default
The standard library opener, urlopen(url), with no timeout argument and no dependency to install.
Wins
It inherits the process wide socket default, so socket.setdefaulttimeout(10) fixes every library in the process at once, including ones whose source you cannot edit.
Loses
That default is None, which means wait forever, and it has been None since the bug asking for a safer value was filed in 2010. The ticket is still open.
960 s and countingsocket default Noneopen since 2010
The core http module directly, the shape of every quick script and most older libraries.
Wins
It emits a timeout event you can listen for, and request.setTimeout plus an explicit destroy gives you precise control over both halves of the problem.
Loses
Nothing is set for you, and the timeout event does not abort the request by itself: you have to call destroy in the handler or the socket stays open after the event fires.
960 s and countingno default socket timeout
The npm package that predates the built in fetch and is still in a great many package.json files.
Wins
It behaves exactly like the browser API it was written to imitate, which is the only reason anyone picked it, and AbortSignal.timeout works with it cleanly.
Loses
It is the trap in this list: the identical fetch(url) call times out at 300 seconds on Node's built in implementation and never times out on this one, so removing the import changes the failure mode silently.
960 s and countingno default timeout
Verdict
Set the timeout yourself, on every call, in every client, and treat Node 22 global fetch as the only one of these eight whose default is even in the right universe. Its 300 seconds is far too long to be useful, but it ends. Check what your client does after a timeout fires, too: wget's 900 second read limit is real, and it answers it with a retry, so twenty fresh connections to the same dead server is five hours rather than fifteen minutes. The same fetch(url) line times out on Node's built in implementation and waits indefinitely through node-fetch, and nothing in the code tells you which one you have. Measure it on a socket that goes quiet.
Sources