Performancerttwaterfallqueueingbufferbloataccept queue

Where the Time Goes: The Request Timeline

A request’s wall-clock time is a sequence of segments — DNS, TCP, TLS, request, queueing in every buffer along the way, server work, response, retransmissions — and most of them can be large while the server’s CPU is idle; label each one and the slow request explains itself.

ConceptualLinuxSimulated
▶ InteractiveInterview question
Progress

The problem

The request took 812 ms. The handler took 6 ms. The server’s CPU is at 12 %. Nothing in the application accounts for 806 ms, and yet they were spent — in handshakes, in queues, in a retransmission timer, and possibly in a 40 ms stall that two well-meaning TCP optimisations produced together.

The waterfall

Simulated

A request is a chain of segments, each waiting on something different. Draw it as a waterfall and label every span; the ones that surprise you are the ones the application never sees. The timeline below is illustrative; the interactive lets you change RTT, loss and connection state and watch which bars grow.

Read it as a budget. The application’s 6 ms is under 1 % of the total; two handshakes are 22 %; one retransmission timeout alone is 28 %; slow-start round trips for the body are a third. Every one of those bars shrinks or vanishes with a warm connection, a shorter path or no loss — and none of them is visible in the server’s own request timer, which starts when the handler runs and stops when it returns.

One cold HTTPS request, cross-region, one lost packet (simulated; RTT 80 ms)
segment                              wait on                              ms
DNS lookup (miss)                    resolver RTT + upstream                  35
TCP handshake                        1 RTT                                    80
TLS 1.3 handshake                    1 RTT + server crypto                    84
request sent → arrives               ½ RTT + serialization                    40
  LB accept + upstream connect       LB queue; pooled → ~0                     3
  server accept queue                backlog wait; idle server → ~0            1
  application work                   CPU + database                            6
  response serialization             size / bandwidth                          2
response → first byte at client      ½ RTT                                    40
lost segment + retransmit            RTO (≥ 200 ms on Linux) or fast retx     210
remaining body (slow start)          3 more RTTs                              240
                                                                           ─────
                                                                             ~740   application: 6

The segments the server never sees

Connection setup. DNS (Following One Lookup Through Every Cache), one RTT for TCP (The Three-Way Handshake), one for TLS 1.3 — two for TLS 1.2 (The TLS Handshake). All of it happens before the server process is involved, and all of it disappears with a warm pooled connection (Keep-Alive and Connection Reuse, Connection Pooling). A service whose p50 is fine and whose p99 is +3 RTTs is usually a service whose connection pool is too small or whose idle timeout closes connections between requests.

Propagation and serialization. The bytes take ½ RTT each way (Latency: Same Machine to Cross-Continent), plus size / bandwidth to put them on the wire (Bandwidth vs Latency). Packet loss turns one lost segment into either a fast retransmit (~1 RTT, if enough later segments arrive to trigger duplicate ACKs) or a retransmission timeout — the minimum RTO on Linux is 200 ms, and a loss of the last segment of a small response, with nothing behind it to trigger fast retransmit, waits for the full RTO. That is a 200 ms hole in the timeline for one dropped packet (Packet Loss: Duplicate ACKs, Fast Retransmit and the RTO).

Retries at any layer multiply the rest. A client that retries after a 1 s timeout on a request that the server completed at 1.1 s has doubled the server’s work and will now receive two responses (or a duplicate side effect). Tail latency is the sum of the slowest segment on each hop; a fan-out to ten backends waits for the slowest of ten, so a p99 on each becomes roughly the p90 of the whole — retries with jitter, hedged requests and deadlines propagated through the chain are the tools, and all of them cost extra work.

Queues: where idle servers hide latency

Linux

Between the client and the handler are at least five queues, and time spent in any of them is invisible to the handler’s own timer. The client’s send buffer and the network’s router buffers: bufferbloat — oversized buffers on a saturated link hold hundreds of milliseconds of other people’s packets, so your small request sits behind a bulk upload; latency climbs while nothing is lost and no CPU is busy (The Buffer Chain). The load balancer’s queue and its pool of upstream connections: a request waits for a free upstream connection when the pool is smaller than the concurrency (Load Balancers: L4 vs L7).

The server’s accept queue: completed handshakes wait here until the application calls accept(). If the process is busy — blocked on a database call in a single-threaded design, or with every worker occupied — the connection sits fully established, the client thinks it is connected, and nothing reads its request. When the queue overflows (Linux somaxconn / the listen() backlog), new SYNs are dropped or ignored and the client retransmits them at 1 s, 3 s, 7 s — the "connects take exactly 1 or 3 seconds" signature. Then the socket receive buffer, where the request waits for read(), and the application’s own work queue or thread pool (The Thread Pool Server, C10K: Ten Thousand Connections, Then a Million). Finally, the send buffer on the way out: a slow client fills it and write() blocks or the event loop stalls (What Happens When the Receiver Is Slow).

The rule for idle-CPU slowness: find the queue. ss -ltn shows the accept queue depth (Recv-Q on a listening socket) against its limit; ss -tn shows per-connection Recv-Q/Send-Q; nstat -a | grep -i listen counts overflows; balancer metrics show upstream queue time; ping under load shows bufferbloat as RTT that climbs with traffic.

Reading queues on a Linux server with an idle CPU and slow requests
$ ss -ltn                       # listening socket: Recv-Q = accept queue depth, Send-Q = its limit
State   Recv-Q  Send-Q  Local Address:Port
LISTEN  511     511     0.0.0.0:8080         ← full: handshakes done, nobody calling accept()

$ nstat -az | grep -i -e ListenOverflows -e ListenDrops
TcpExtListenOverflows  4821                  ← SYNs dropped because the accept queue was full
TcpExtListenDrops      4821

$ ss -tn state established '( sport = :8080 )' | head
Recv-Q  Send-Q  Local Address:Port  Peer Address:Port
65536   0       10.0.0.5:8080       10.0.3.7:51234   ← request bytes sitting unread in the socket buffer

The 40 ms stall: Nagle meets delayed ACK

Linux

Two TCP optimisations, each sensible alone, deadlock for tens of milliseconds together. Nagle’s algorithm (on by default) holds back a small segment while a previous small segment is still unacknowledged, to coalesce dribbles of tiny writes into fewer packets. Delayed ACK (also default) has the receiver wait — up to 40 ms on Linux, 200 ms on some other stacks — before acknowledging a lone segment, hoping to piggyback the ACK on data of its own.

Now a client does two write()s for one request — a header, then a body — and waits for the reply. The first small write goes out. The second is held by Nagle until the first is ACKed. The server has received a partial request, has nothing to send, and delays its ACK. Both sides wait; the delayed-ACK timer fires after 40 ms; the ACK arrives; Nagle releases the second write; the request completes. Every request pays a fixed ~40 ms for nothing, and the server’s handler time is unchanged. The signature is a latency histogram with a spike at exactly RTT + 40 ms, on requests whose bodies are sent in more than one write.

Fixes: set TCP_NODELAY on the socket (disables Nagle; every serious HTTP client, database driver and RPC library does so, but hand-written clients forget), or write the request in one buffer, or TCP_CORK it and uncork once. Go, Node.js and most modern runtimes set TCP_NODELAY by default on outbound connections; check before assuming.

The write-write-read pattern that triggers the stall, and the one-line fix
1sock.write(header) # small segment 1: sent immediately
2sock.write(body) # small segment 2: Nagle holds it until segment 1 is ACKed
3reply = sock.read() # server: has a partial request, nothing to send, delays the ACK ~40 ms
4 # → both sides wait; delayed-ACK timer fires; then the body goes out
5
6# fix 1: sock.setsockopt(IPPROTO_TCP, TCP_NODELAY, 1) # disable Nagle
7# fix 2: sock.write(header + body) # one write, one segment

Reading a slow request

The procedure, in the order that finds the answer fastest. Is the connection warm or cold? (Client-side timing — curl -w gives time_namelookup, time_connect, time_appconnect, time_starttransfer — separates handshakes from everything else.) Is the RTT what you expect? (ping; a climbing RTT under load is bufferbloat.) Was there loss? (ss -ti retrans counters; a 200 ms step is an RTO.) Where is the queue? (accept queue, socket buffers, pool wait time, balancer queue.) Is there a fixed 40 ms? (Nagle.) Only then: is the handler slow?

The slow-request-idle-server question is an interview staple because it separates people who think in application time from people who think in wall-clock time. The application is one bar on the waterfall. Everything else is the network, the kernel and the queues between them, and none of it shows up in CPU.

curl’s built-in waterfall
$ curl -o /dev/null -s -w 'dns %{time_namelookup}  tcp %{time_connect}  tls %{time_appconnect}  ttfb %{time_starttransfer}  total %{time_total}\n' https://api.example.com/orders
dns 0.034  tcp 0.115  tls 0.201  ttfb 0.612  total 0.740
#   ↑ 34 ms   ↑ +81 ms   ↑ +86 ms   ↑ +411 ms: request + queueing + server + ½ RTT   ↑ body

Key points

  • A request is a chain of segments; the handler is one of them. Label every one before blaming any.
  • Cold connections cost 2–3 RTTs (DNS, TCP, TLS) that a warm pool makes vanish; p99 = p50 + 3 RTTs usually means pool problems.
  • One lost packet costs a fast retransmit (~1 RTT) or a full RTO (≥ 200 ms on Linux) when nothing follows it.
  • Queues — router buffers (bufferbloat), balancer queues, the accept queue, socket buffers, worker pools — add latency with zero CPU. Find the queue.
  • A full accept queue makes connections take exactly 1 s or 3 s (SYN retransmits); ss -ltn and ListenOverflows show it.
  • Nagle + delayed ACK: write-write-read costs ~40 ms per request; TCP_NODELAY or a single write fixes it.
  • Tail latency compounds across fan-out and retries; deadlines and jittered retries are the tools.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why can a request be slow with the CPU idle?

Because most of the time is spent waiting — for the trip, for a handshake, for a retransmission timer, in a queue — and waiting uses no CPU. The CPU measures work, not time.

Why does the accept queue exist?

The kernel completes handshakes on the application’s behalf so that a busy process does not drop connections; the completed ones have to wait somewhere. The queue is that somewhere, and it has a size.

Why is Nagle on by default if it causes this?

It was designed for terminals that sent one keystroke per packet and it still saves bandwidth for that pattern. Request/response protocols with multi-write requests are the pattern it hurts, which is why they disable it.

Why is the minimum RTO 200 ms when the RTT is 1 ms?

To tolerate delayed ACKs: a timer shorter than the receiver’s ACK delay would fire spuriously on every quiet connection. The price is a 200 ms hole whenever the last segment of a burst is lost.

Request waterfall

One HTTPS request, as a waterfall
The server is busy for a slice of this; the rest is the network's arithmetic. Change one input and watch which bar moves.
Simulated
DNS lookup60 msTCP connect60 msTLS handshake60 msRequest sent30 msWaiting (server)60 msLoss stallskippedContent download273 ms
Total
543 ms
Server busy
30 ms (6%)
Time to first byte
270 ms
Fraction of the request during which the server is idle94%
Segments
  • 60 msDNS lookup — stub → recursive resolver; often cached, then ~0
  • 60 msTCP connect — SYN → SYN-ACK → ACK: one RTT before any byte
  • 60 msTLS handshake — TLS 1.3: one RTT (TLS 1.2 needs two)
  • 30 msRequest sent — half an RTT to reach the server
  • 60 msWaiting (server) — 30 ms of server work + half an RTT for the first byte to come back
  • 0 µsLoss stall — no loss
  • 273 msContent download — 200.0 KB = 141 segments: 33 ms on the wire + 4 slow-start round trips (cwnd 10 → 160)
The server worked for 30 ms of a 543 ms request. Its CPU graph is flat and its logs say "handled in 30 ms" — yet the user waited 543 ms. Slow ≠ busy: the handshakes own this timeline.

How it fails

What the failure looks like from inside real software.

  • p50 40 ms, p99 400 ms, server idle: connection pool too small or idle timeout shorter than the request gap; most requests warm, the tail cold.
  • Latency histogram has a spike at exactly RTT + 40 ms: Nagle and delayed ACK; a client that writes header and body separately without TCP_NODELAY.
  • Connections take exactly 1 s or 3 s to establish, then are fast: accept queue overflowing; SYN retransmits; the process is not calling accept() fast enough.
  • RTT to the server climbs from 5 ms to 300 ms whenever a backup runs: bufferbloat on a saturated uplink; the request waits behind the bulk flow’s packets.
  • Small responses occasionally take +200 ms with no pattern: the last segment lost, no fast retransmit possible, full RTO.
  • Fan-out to 20 backends has p50 of 300 ms though each backend’s p50 is 30 ms: the caller waits for the slowest; tail latency compounds; hedge or reduce fan-out.