Connections, limits, and protocol
Every stage below can be a place a connection stops making progress, so every stage has a bound.
listener.accept() │ ← blocked here while at maxConnections (permit) │ ← errors retried with 5ms→1s backoff, never fatal ▼ TLS handshake timeouts.handshake (10s) ▼ first byte / version detection timeouts.handshake (10s) │ HTTP/2 preface? → h2c else → HTTP/1.1 ▼ request head timeouts.headerRead (30s) ▼ handler ── request body ── response unbounded, by design ▼ idle h1: timeouts.headerRead · h2: timeouts.h2KeepAlive ▼ close
Accept
The accept loop retries every error rather than ending. ECONNABORTED (a client that hung up between the SYN and the accept), EMFILE/ENFILE (a momentarily full descriptor table) and EINTR are ordinary on a busy public port and say nothing about the listening socket. The wait between attempts doubles from 5ms to a ceiling of 1s and resets on the next accepted connection, so one transient failure costs 5ms while a persistent one settles at a wakeup per second instead of spinning a core. Each retry logs at warn on the runtime::http target.
A loop that exited here would leave the port bound and the server dead — nothing served, and nothing else able to take the address.
TLS handshake and first byte
Both are bounded by timeouts.handshake, and a TLS connection passes through both, so it can take up to twice that value before it counts as established.
They are separate mechanisms. The handshake is wrapped in a timeout because rustls will otherwise wait for the peer's next flight indefinitely. The first-byte wait cannot be: version detection reads up to 24 bytes — the length of the HTTP/2 connection preface — before either kind of hyper connection exists, so no timer hyper owns is running yet. A wrapper around the stream applies the deadline until one byte arrives and goes inert afterwards, because a long-lived connection must not be interrupted by it.
Request head, and the idle connection
timeouts.headerRead bounds how long a request head may take to arrive. On HTTP/1.1 it is also the idle keep-alive limit — not a second timer, the same one. hyper arms it whenever it waits for a request head, and waiting for the next request on a kept-alive connection is exactly that. At the default, an idle connection is closed after 30s and a client that wants another request opens a new one.
HTTP/2 does not work this way. Its connections are long-lived by design, so there is no idle limit; instead an idle connection is probed with a PING every timeouts.h2KeepAlive, and dropped if the ACK does not arrive within the same interval. Without probing, a peer that vanishes without a FIN — a NAT that dropped the mapping, a killed VM, an unplugged cable — keeps its connection and its share of the stream budget until the OS TCP keepalive notices, which is two hours by default on Linux.
The request body, which elapsed time cannot judge
headerRead stops the moment the head is complete. A peer that sends a well-formed head and then dribbles its body one byte at a time is past every timer above it — slowloris, one phase later, against a clock that has already stopped.
A flat cap cannot answer that. Over elapsed time alone a 100 MiB upload on a slow link and a dribbler are the same connection; any cap generous enough for the upload is generous enough for the attack. What separates them is not how long they take but how much they send while taking it. So the body's deadline is earned:
deadline = timeouts.bodyRead + received / timeouts.bodyMinRate
At the defaults (30s, 1 KiB/s) a 100 MiB upload has over a day, a 1 GiB one over a week, and a peer sending a byte a minute is closed at ~30s having earned about 10 milliseconds. The rate is a floor to beat, not one to sustain — a genuinely slow client only has to beat a hundredth of a dial-up modem. Apache's mod_reqtimeout uses the same shape, for the same reason; Node and nginx bound the body by idle time instead, which a dribbler resets with every byte.
What is deliberately not bounded
A request in flight and a response still streaming have no deadline, however long they take. A live feed, a slow query, and a large download are all indistinguishable from a stalled connection if you only look at elapsed time, so elapsed time is not what these timeouts look at.
Still unbounded today: total request duration. That is a decision rather than a gap — a total cap would break SSE and long-polling, and there is no per-route override to escape it.
Every limit, in one place
| Limit | Default | Scope | Set by |
|---|---|---|---|
timeouts.handshake | 10s | per connection | serve({ timeouts }) |
timeouts.headerRead | 30s | per connection | serve({ timeouts }) |
timeouts.h2KeepAlive | 20s | per connection | serve({ timeouts }) |
timeouts.bodyRead | 30s + earned | per request | serve({ timeouts }) |
timeouts.bodyMinRate | 1 KiB/s | per request | serve({ timeouts }) |
maxConnections | unlimited | per server | serve({ maxConnections }) |
maxConnectionsPerIp | unlimited | per peer address | serve({ maxConnectionsPerIp }) |
| HTTP/1.1 header fields | 100 | per request | fixed |
| HTTP/1.1 read buffer | ~408KB | per connection | fixed |
| HTTP/2 header list | 16KB | per request | fixed, advertised in SETTINGS |
| HTTP/2 concurrent streams | 256 | per connection | fixed, advertised in SETTINGS |
| Request queue between host and isolate | 1024 | per server | fixed |
| Response body chunks in flight | 8 | per response | fixed |
Timeouts are on by default and disabled per option with null. The connection cap is off by default: the right number follows from a deployment's file-descriptor budget and the memory a connection costs, neither of which the runtime can read.
Sizing a deployment
The read buffer is the number that multiplies. An HTTP/1.1 connection's buffer can reach ~408KB, so ten thousand connections that are valid, idle, and doing nothing is roughly 4GB the server will allocate on its way to the descriptor limit. That is what maxConnections is for, and why it is worth setting on a public port even though nothing forces you to.
The cap is enforced by not accepting. A permit is taken before accept and released when the connection ends, so a connection over the limit waits in the kernel's backlog and costs the server nothing at all — no descriptor, no task, no buffer — until a slot frees, at which point it is served. It is a queue, not a rejection. Once the backlog itself fills, the OS refuses further connections, which is the only refusal in the design and it also costs the server nothing.
HTTP/2 concurrency against the isolate
The 256-stream cap exists because JavaScript runs on one thread. An HTTP/2 peer opens streams far faster than a single-threaded isolate answers them, and every open stream holds a queued request plus its body channel. The cap bounds what one connection can make the server hold, which leaves the 1024-slot request queue for spreading across connections rather than being filled by one client.
Protocol version
The version is the client's choice, decided per connection, and the handler never sees which one carried a request.
Over TLS it is ALPN: serve() advertises ["h2", "http/1.1"] — h2 first, because ALPN order is the server's preference — and the client takes the first it speaks. On a cleartext port it is the HTTP/2 connection preface: a connection opening with those 24 bytes is read as h2c by prior knowledge, anything else as HTTP/1.1. There is no Upgrade:-header dance; that mechanism is deprecated and no client relies on it.
What changes on the wire, with the handler untouched:
| Multiplexing | many requests in flight on one connection, answered in any order |
| Handshakes | one TLS handshake per session, not per connection |
| Headers | HPACK-compressed instead of resent in full each request |
request.url | rebuilt from :authority, which replaced the Host header |
| Framing | the version frames bodies; a handler's own Content-Length and Transfer-Encoding are dropped, and HTTP/2 forbids chunked encoding outright |
Multiplexing needed nothing new above the socket. Responses were already matched to requests by id rather than by arrival order, so answering three streams out of order is the same code path as answering three connections.
Whether HTTP/2 is faster depends entirely on how the client connects — see the benchmark.