Chapter 26 — The 40-millisecond mystery
In one sentence
Two individually-sensible TCP optimisations — Nagle's algorithm and delayed acknowledgements — can deadlock each other, adding a fixed 40 ms delay to every request, and this bug is everywhere.
The two optimisations
Nagle's algorithm (sender side). Stops tiny packets flooding the network:
"If I have data still unacknowledged, don't send another small packet — wait until I have a full-sized chunk, or until the outstanding data is acknowledged."
Sensible: sending a 1-byte payload with 40 bytes of headers is 97% waste.
Delayed ACK (receiver side). Stops pure-acknowledgement packets flooding the network:
"Don't acknowledge immediately. Wait up to ~40 ms in case I have data to send back — then I can piggyback the ACK on it for free."
Also sensible.
How they deadlock
Now put them together, with an application that sends a request in two writes (very common — a header, then a body):
CLIENT (Nagle on) SERVER (delayed ACK on)
write(header) -> sent immediately
(nothing unacknowledged yet)
------> got the header.
Nothing to reply with yet.
"I'll wait ~40 ms before ACKing,
in case I get more."
write(body) -> Nagle: "the header is
unacknowledged and this
is small -- I'll WAIT."
... BOTH SIDES ARE NOW WAITING FOR EACH OTHER ...
(40 ms later) the delayed-ACK timer fires ------> ACK
ACK arrives -> Nagle releases the body ------> now the server can work
Every single request costs an extra 40 milliseconds. The network is fine. The CPUs are idle. Nothing is broken. It's just two politeness rules colliding.
The fingerprint
If your latency graph has a hard floor at 40 ms (or 200 ms on some systems), and the network round-trip time is under 1 ms, suspect this immediately.
Typical symptoms:
- Requests take suspiciously exactly 40 ms
- Throughput caps at a suspiciously round number like 25 requests/second (= 1 / 0.04)
- It only happens on small requests; big ones are fine
- It's worse locally than over the internet (because the real RTT is tiny in comparison)
The fix
Any one of these:
- Set
TCP_NODELAYon the socket (disables Nagle). This is what virtually every RPC framework, database driver, and game server does. One line:
import socket
s = socket.create_connection(("example.com", 80))
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
-
Write the whole request in one call. Build the message in memory, then one
send(). Nagle never engages because there's no small-then-small pattern. This is the cleanest fix. -
TCP_QUICKACKon the receiver (Linux) to suppress delayed ACKs.
Practice (20 min)
A. Check whether your tools set it. Many do:
# Linux -- watch a program's socket options as it runs
strace -f -e trace=setsockopt curl -s https://example.com 2>&1 | grep -i nodelay
B. Measure a suspiciously round latency. If you have a local service, time many small requests:
for i in $(seq 1 20); do
curl -s -o /dev/null -w '%{time_total}\n' http://localhost:8000/
done | sort -n
Values clustered at 0.04 s with a local server = the fingerprint.
C. Recognise the sibling problem. Also watch for delays that are exact multiples of common timers: 200 ms (retransmission timeout, Chapter 22), 1 s / 3 s / 7 s (SYN retries, Chapter 21), 5 s (a dead DNS server, Chapter 18). Suspiciously round numbers in latency are always a timer, never a coincidence.
Build it in Go (20 min) — the 40 ms mystery, on demand
In Go, the fix above is already applied. Every TCP connection Go creates has
TCP_NODELAY set by default. That's why Go services rarely hit this bug,
and why it's confusing when they talk to a peer written in something else. To
see the problem, this lab turns Nagle back on with SetNoDelay(false) and
uses the classic "write-write-read" pattern:
// nagle: reproduce Chapter 26's "40 ms mystery" on purpose.
//
// Go sets TCP_NODELAY on every TCP connection by default (Nagle OFF), so
// Go programs rarely hit this -- unless someone turns Nagle back on, or the
// peer is a program in another language. Here we turn it on deliberately.
//
// go run ./nagle
package main
import (
"bufio"
"fmt"
"net"
"slices"
"time"
)
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
go echoServer(ln)
for _, noDelay := range []bool{true, false} {
c, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
panic(err)
}
tc := c.(*net.TCPConn)
tc.SetNoDelay(noDelay) // false = Nagle's algorithm ON
r := bufio.NewReader(c)
var lat []time.Duration
for i := 0; i < 20; i++ {
start := time.Now()
// The classic bad pattern: header and body in TWO small writes,
// then wait for a reply ("write-write-read").
c.Write([]byte("HEADER:"))
c.Write([]byte("body\n"))
if _, err := r.ReadString('\n'); err != nil {
panic(err)
}
lat = append(lat, time.Since(start))
}
c.Close()
slices.Sort(lat)
fmt.Printf("TCP_NODELAY=%-5v p50=%-10v max=%v\n", noDelay, lat[len(lat)/2], lat[len(lat)-1])
}
fmt.Println("\nIf NODELAY=false is ~40ms (Linux) or higher, you just met Nagle + delayed ACK.")
fmt.Println("Fix: one write per message (bufio.Writer + Flush), or keep NODELAY on.")
}
func echoServer(ln net.Listener) {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
r := bufio.NewReader(c)
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
c.Write([]byte(line))
}
}()
}
}
$ go run ./nagle # Linux
TCP_NODELAY=true p50=5.709µs max=49.25µs
TCP_NODELAY=false p50=43.952667ms max=46.074958ms
Four orders of magnitude, caused by one socket option and two small writes. On macOS loopback the effect often doesn't appear, because delayed ACK behaves differently there. Run it on Linux (or in the container from Section 0.8) to see the real fingerprint.
What to notice:
- The fix in the output, "one write per message", is the cleanest one.
bufio.Writercollects the header and body andFlush()sends them in a singlewrite(), so Nagle never has a small packet to hold back. SetNoDelayis a thin wrapper aroundsetsockopt(fd, IPPROTO_TCP, TCP_NODELAY, ...), the same call the Python snippet above makes.
Exercises:
- Keep
SetNoDelay(false)but wrap the connection in abufio.WriterandFlush()after the body. Does the 40 ms disappear? - Run the server under
strace -e trace=setsockoptand confirm Go setTCP_NODELAYon the accepted connection too. - Throughput is 1 / 44 ms ≈ 23 requests per second per connection. Find the "suspiciously round number" in that.
Common confusions
- "Should I always disable Nagle?" For interactive/request-response traffic,
yes. For bulk transfer, leaving it on is slightly more efficient. Most modern
libraries set
TCP_NODELAYby default now. - "Is this still a real problem?" Less than it was, because frameworks
default to
TCP_NODELAY— but it still appears regularly in custom protocols, older software, and hand-rolled socket code.
Check yourself
- What does Nagle's algorithm do, and why?
- What does delayed ACK do, and why?
- Describe how they deadlock.
- Name two ways to fix it.
Further reading
- Article (the classic): "It's always TCP_NODELAY. Every damn time." — search that phrase; several good writeups exist.
- Article: John Nagle's own comments on Hacker News about the interaction — search "Nagle delayed ACK Hacker News"; the algorithm's inventor explains why the combination is the problem, not his algorithm.
- Docs:
man 7 tcpon Linux — theTCP_NODELAY,TCP_CORK, andTCP_QUICKACKsections.