What stays open

A running server is a process that holds files. Every accepted TCP client spends a file descriptor. So does every idle socket in a client pool, every open file you are serving, and every log the process still has. When the table is full, the next accept, the next outbound call, and the next log line all fail the same way.

This page is the clocks and caps that keep that table from growing without bound. The HTTP page is the surface. Reference is the full field list.

A connection is a file

maxConnections is the cap we put on ourselves before the operating system does. The default is 1024. The extra handshake is closed, not queued forever.

The listen backlog is a different number. soBacklog (default 100) is how many handshakes the kernel will hold before it starts refusing. Independent of how many connections we are already serving.

(
  Server.Config.default.maxConnections,
  Server.Config.default.soBacklog,
  Server.Config.default.soKeepAlive,
)
(1024,100,true)

Keep-alive is a pile of sockets

HTTP/1.1 leaves the connection open for the next request. That is faster until a client goes quiet. A quiet socket still occupies a descriptor, a buffer, and a thread.

idleTimeout (default 60 seconds) closes a connection that is not reading or writing. Duration.Infinity is the explicit never, and it is a choice you own.

A busy connection is not closed for being busy. maxRequestsPerConnection defaults to none, so a hot client is not forced to reconnect. Set Some(KeepAliveRequests(n)) when a balancer should see a fresh connection after n requests. That path counts. The default does not.

A client that sends one header line every thirty seconds and never finishes the request is not idle between requests. It is still sending. headerTimeout (default 30 seconds) is that clock. Incomplete headers die even if bytes keep dribbling in. The next section puts that clock on one shared profile.

(
  Server.Config.default.idleTimeout,
  Server.Config.default.headerTimeout,
  Server.Config.default.copy(idleTimeout = Duration.Infinity).idleTimeout,
)
(PT1M,PT30S,PT2562047H47M16.854775807S)

One profile, in ratios

Heddle was compared with zio-http 3.11.6 on one shared profile. The header cap is 16 KiB. The body cap is 10 MiB. Both are heddle's defaults, and zio-http is configured to the same numbers, so a size row is the same cap on both servers. One read deadline is set on both. Heddle uses it as headerTimeout and as idleTimeout. zio-http has a single read timer, and a byte restarts that timer. Heddle's header budget stays where it was stamped. zio-http 3.11.6 has no connection cap. The probe sets one on heddle.

Stopwatch readings belong to the machine that ran the probe. The table is in multiples of the shared deadline and the shared caps. scripts/bench-safety-heddle.sh and scripts/bench-safety-zio-http.sh rerun it, one process at a time. Production defaults stay 30 seconds, 60 seconds, and 1024 connections. The probe only shortens the read deadline and the connection cap, so the ratios show up. Header and body caps stay at the defaults. The relationship is the same at the default clocks.

ProbeAgainst the profileHeddlezio-http 3.11.6
Finished junk request line, left open or half-closedcomplete request line400, without waiting out the deadline400, without waiting out the deadline
Partial headers, then silence1× the deadline408socket closes, no status
Partial headers, one byte every 1/5 of the deadlinethe budget is the whole deadline408 at 1× the deadlinestill open past the deadline. Each byte restarts the timer
Header block3/8 of the header cap, and again at 3/4200200
Header block5/4 of the header cap431431
Bodyabout 1.5% of the body cap200200
Declared body, nothing sent11/10 of the body cap413 from the length413 from the length
Declared body, then the bytes11/10 of the body cap413, and the body is not read through413, and the body is not read through
Content-Length together with Transfer-Encodingframing400400
Content-Length is not a run of digitsframing400400
Two Content-Length headers, same valueframing400400
One connection past the capheddle's cap is set. zio-http 3.11.6 has nonerefusedserved

A body under the cap is accepted. zio-http aggregates that body. Heddle streams it. The status is 200 either way. The body cap is the 11/10 rows: both answer 413 from the declared length, and neither reads the body through.

Silence and a dribble are different failures. Silence ends both servers at the deadline. Bytes that never finish the header end heddle at that same deadline. They leave zio-http open while they keep arriving.

The handler timeout is not the socket timeout

Middleware.timeout turns a slow route into 504. It does not close the TCP connection by itself. Socket clocks live on Server.Config. Put the 504 on the Routes that should have it; put idle and header limits on the bind.

{
  Middleware.timeout(2.seconds)
  true
}
true

Uploads and memory

maxBodyBytes (default 10 MiB, BodyBytes) is a cap on the request, not a buffer we always allocate. maxHeaderBytes (default 16 KiB, HeaderBytes) is the header-block cap and the per-connection read buffer: each accepted socket allocates max(chunkSize, maxHeaderBytes). HTTP/2 keeps a separate header-list cap (maxHeaderListSize, default 8 KiB). BytesLength (64.K, 10.M) is the size type for a body or a middleware cap, not these config fields.

If the handler never reads the body, the server still drains leftovers so the next keep-alive request is not garbage.

HTTP/2 windows exist so a fast sender cannot fill an unbounded queue while the handler is slow. DATA past the stream window is reset. maxConcurrentStreams (default 100) refuses extras. Writer and body queues are bounded (http2Config.maxOutstandingFrames, default 64).

(
  Server.Config.default.maxHeaderBytes,
  Server.Config.default.maxBodyBytes,
  Server.Config.default.http2Config.maxConcurrentStreams,
)
(16384,10485760,100)

Gzip is a Routes wrap

Compression is not a server flag. @@ wraps this Routes value. ++ tries the left, then the right when the left has no matching route. A response the left produced stands, including a 404 or a 405.

Global gzip is still one line, at composition time:

val app = (api ++ files) @@ Middleware.compress() ++ sse
Server.serve(app).provide(Server.Config.defaults)

Partial gzip leaves event streams alone:

val app = (api @@ Middleware.compress()) ++ files ++ sse

If gzip lived on Server.Config, you would compress everything on the bind, including routes that must not be content-encoded, and you would have no way to say "these yes, those no" without a second process.

Incoming Content-Encoding is @@ Middleware.decompress(maxBytes = ...). Inflating without a cap is how a small request becomes a large heap. The default cap is Server.Config.defaultMaxBodyBytes.

{
  val compressed =
    Routes(Method.GET / "a" -> Handler.text("a" * 2048)) @@ Middleware.compress(minBytes = 16)
  val plain  = Routes(Method.GET / "b" -> Handler.text("b" * 2048))
  val routes = compressed ++ plain
  for
    a <- routes(Request.get("/a").withHeader("Accept-Encoding", "gzip"))
    b <- routes(Request.get("/b").withHeader("Accept-Encoding", "gzip"))
  yield (
    a.header("Content-Encoding").contains("gzip"),
    b.header("Content-Encoding").isEmpty,
  )
}
(true,true)

Files and upgrades

Serving a file opens a channel that must close on interrupt. A WebSocket or SSE that goes quiet is still a connection. It uses the same idle story unless you set Duration.Infinity because the protocol is long-lived on purpose.

heddle.Server.Config.default.gracefulShutdownTimeout
PT10S

The client is a process too

A pool of idle sockets to a host you stopped calling still counts as open files. Client.Config caps in-flight connections per host (maxConnectionsPerHost, default 10) and idle keepers (maxIdlePerHost, default 10). poolIdleTimeout (default 60 seconds) closes idle pooled sockets. connectTimeout (default 10 seconds) and idleTimeout (default 60 seconds) stop a hung peer from blocking a fiber forever.

Interrupting a call (a timeout, a lost race) closes its socket and frees its slot. A body that ends when the server closes the socket (no Content-Length, not chunked) is never pooled, and neither is a Connection: close exchange or an HTTP/1.0 one without keep-alive. Bodies past maxBodyBytes fail with ClientError.Protocol(_, HttpError.BodyTooLarge), however they are framed.

Client.get does not add Accept-Encoding. If you want gzip, set the header. The client inflates a gzip body only when that request asked for gzip.

(
  Client.Config.default.maxConnectionsPerHost,
  Client.Config.default.connectTimeout,
  Client.Config.default.poolIdleTimeout,
)
(10,PT10S,PT1M)