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.
The problem
The waterfall
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.
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: 6The 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
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.
$ 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
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.
1sock.write(header) # small segment 1: sent immediately2sock.write(body) # small segment 2: Nagle holds it until segment 1 is ACKed3reply = sock.read() # server: has a partial request, nothing to send, delays the ACK ~40 ms4 # → both sides wait; delayed-ACK timer fires; then the body goes out5 6# fix 1: sock.setsockopt(IPPROTO_TCP, TCP_NODELAY, 1) # disable Nagle7# fix 2: sock.write(header + body) # one write, one segmentReading 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 -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 ↑ bodyKey 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 -ltnandListenOverflowsshow it. - Nagle + delayed ACK: write-write-read costs ~40 ms per request;
TCP_NODELAYor 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
- 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)
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.