Skip to content

fix(server): set TCP_NODELAY on accepted connections - #218

Merged
shikhar merged 1 commit into
mainfrom
cc/tcp-nodelay
Nov 13, 2026
Merged

shikhar merged 1 commit into
mainfrom
cc/tcp-nodelay

Conversation

@shikhar

@shikhar shikhar commented Nov 13, 2026

Copy link
Copy Markdown
Member

Why

The server never sets TCP_NODELAY. axum-server's default acceptor leaves Nagle's algorithm on, and turning it off is opt-in via NoDelayAcceptor.

Under HTTP/2, a fetch response ends with a small separate frame: the trailers frame from the body stream, or an empty END_STREAM data frame. That frame often goes out while the previous small segment is still unacknowledged, so Nagle holds it until the ACK arrives. A Linux client delays that ACK by up to 40 ms, so the client finishes reading the response about 40 ms late.

HTTP/1.1 clients don't hit this. With Content-Length the response ends at its last byte, and no trailing frame is sent.

We saw this after moving a client from HTTP/1.1 to HTTP/2 with prior knowledge. Client-observed fetches in the 40–80 ms range became about 7× more common. The server's own first-chunk and download latencies didn't change.

Repro

A minimal Linux loopback test mirrors the fetch response: a 206 with Content-Length, the body streamed through StreamBody, and a trailing trailers frame. The client is reqwest with http2_prior_knowledge(), or HTTP/1.1 for comparison. The server is axum-server 0.8 with rustls, with and without NoDelayAcceptor. Responses are 2 KiB, 2000 requests per case:

client concurrency Nagle (current) TCP_NODELAY
HTTP/1.1 1 p99 6.7 ms, 0% > 30 ms p99 6.6 ms, 0% > 30 ms
HTTP/2 1 p50 47 ms, 99.9% > 30 ms p99 6.8 ms, 0% > 30 ms
HTTP/2 8 p99 50 ms, 27% > 30 ms p99 6.9 ms, 0% > 30 ms

On a real network the stall happens less often than on loopback, because other traffic on the connection usually carries the ACK sooner. The fix is the same either way.

What

Use NoDelayAcceptor for all three listener modes: TLS with a provided certificate, self-signed TLS, and plain HTTP.

🤖 Generated with Claude Code

With Nagle enabled, the small frame that ends an HTTP/2 response (trailers
or END_STREAM) can be held until the client's delayed ACK, adding ~40ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

Adds NoDelayAcceptor to all three listener modes to avoid delayed HTTP/2 response completion.

  • Every server listener disables Nagle on accepted connections.

Reviews (1) · Last reviewed commit: "fix(server): set TCP_NODELAY on accepted..." · Reviewed by Greptile

@shikhar
shikhar merged commit e859f3e into main Nov 13, 2026
8 checks passed
@shikhar
shikhar deleted the cc/tcp-nodelay branch November 13, 2026 00:29
@github-actions github-actions Bot mentioned this pull request Nov 13, 2026
shikhar added a commit that referenced this pull request Nov 13, 2026
Uses features from axum-server 0.9 (#220) and simplifies the server
setup. Only `src/bin/server.rs` changes.

## Accept errors

axum-server drops connection failures silently unless an
`accept_error_handler` is set. The handler now logs them:

- **Listener failures**, such as running out of file descriptors, log at
`warn`. axum-server waits 50 ms between retries, so this is at most
about 20 lines a second.
- **Connection setup failures**, mainly failed or timed-out TLS
handshakes, log at `debug` with the client address. Load-balancer health
checks and scanners make these routine. The server's subscriber is built
without `env-filter` and only shows `info` and above, so these stay
hidden as before.

## TCP keepalive

`TcpConfigAcceptor` replaces `NoDelayAcceptor`. It keeps `TCP_NODELAY`
from #218 and adds keepalive: probes start after 60s idle, repeat every
10s, and give up after 3 failures.

Without it, the server has no idle timeout on HTTP/1.1 or HTTP/2. A
client that disappears without closing its connection, because its host
died or a NAT or load balancer dropped the flow, leaves it open
indefinitely. The connection holds a task and a file descriptor and
counts in `cachey_connections`. Long-lived HTTP/2 connections make this
more likely.

## Server setup

The three TLS modes each repeated the bind, acceptor, handle and serve
steps. Now the TLS match only produces an `Option<RustlsConfig>` and the
server is configured once. The TLS config also loads before the cache is
built, so a bad certificate path fails before cache startup. The startup
log now reports the certificate source and the listen address on
separate lines.

## Testing

Clippy with warnings denied, rustfmt, and `cargo nextest run --locked
--all-features` (206 passed, 1 skipped) pass. The integration tests
serve through `axum::serve`, so I also ran the binary locally on macOS:

- **TLS (`--tls-self`):** HTTPS requests succeed over HTTP/2. Plain HTTP
to the TLS port and a bare connect-and-close log nothing at the default
level.
- **With `ulimit -n 64`:** opening 80 connections logged `failed to
accept connection` warnings (`Too many open files`). The server kept
serving afterwards.
- **Socket options:** `lsof` shows `SO=KEEPALIVE=60000` on an accepted
socket, and the TCP flags include `TF_NODELAY`.
- **Shutdown:** both TLS and plain HTTP modes exit cleanly on SIGTERM.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
shikhar pushed a commit that referenced this pull request Nov 13, 2026
## 🤖 New release

* `cachey`: 0.13.4 -> 0.13.5

<details><summary><i><b>Changelog</b></i></summary><p>

<blockquote>

##
[0.13.5](0.13.4...0.13.5)
- 2026-11-13

### Added

- *(server)* log accept errors and enable TCP keepalive
([#221](#221))

### Fixed

- *(server)* set TCP_NODELAY on accepted connections
([#218](#218))

### Other

- *(deps)* upgrade dependencies
([#220](#220))
</blockquote>


</p></details>

---
This PR was generated with
[release-plz](https://github.com/release-plz/release-plz/).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant