The Runtime Theory
Networking

TCP Nagle and Delayed ACK: Why Small Writes Stall for 40ms

The Nagle algorithm and delayed ACK interaction that adds a 40ms stall to small writes, how to see it in tcpdump, and when TCP_NODELAY actually helps.

The Runtime Theory Team3 min read#tcp#nagle#latency#tcp-nodelay#networking
On this page

Your code wrote 8 bytes. The peer received them 40 ms later. Nobody configured a delay. This is the most famous silent latency bug in networking, and it's not one bug — it's two TCP mechanisms, each sensible in isolation, colliding on your connection. This article shows you the collision in packet traces and the exact switch that resolves it.

The two suspects

Nagle's algorithm (RFC 896, 1984). A sender-side rule designed to stop the "tinygram" problem: when a TCP connection is born for remote login, programs emit one byte at a time, and each byte was becoming its own 40-byte packet. Nagle's rule: if there is unacknowledged data in flight, don't send a new segment until the previous data is ACKed — unless the new data fills a full segment (MSS) or half the window. Small writes are therefore batched: write 8 bytes, send; write 8 more before the ACK returns, and they wait.

Delayed ACK (RFC 1122, 1989). A receiver-side rule: don't ACK every segment immediately; hold the ACK up to 40 ms (200 ms on some stacks; Linux's default is tcp_delack_max = 40 ms) hoping to piggyback it on the response data. For bulk transfers, the receiver is usually writing data back anyway, so the ACK rides along free — the delay is invisible.

Each rule saves a packet per interaction. Combined, they create a deadlock:

The 40 ms trap

text
sender                        receiver
  |── [seg 1: 8 bytes] ────────>|
  |        (in flight, unacked) |
  |    Nagle: block new writes  |    Delayed ACK: wait up to 40ms
  |    until seg 1 is ACKed     |    hoping to piggyback an ACK
  |                             |
  |<── [ACK (after ~40 ms)] ────|
  |    Nagle unblocks           |
  |── [seg 2: 8 bytes] ────────>|

Every write in the window waits roughly 40 ms — the delayed-ACK timer — because Nagle refuses to send while a segment is unacknowledged, and the receiver refuses to ACK promptly because it expects more data. The two timers never trigger each other; the packet just waits out the full ACK delay. This is why chat apps and request-response protocols feel "laggy" under Nagle+delayed ACK, while bulk downloads (where the window is full of segments and ACKs ride on data) are unaffected.

Seeing it, not just believing it

bash
$ tcpdump -ni any tcp port 8080 -ttt
00:00:00.000000 10.0.0.5.44122 > 10.0.0.9.8080: Flags [P.], seq 1:9, 8 bytes
00:00:00.040095 10.0.0.9.8080 > 10.0.0.5.44122: Flags [.], ack 9
00:00:00.000071 10.0.0.5.44122 > 10.0.0.9.8080: Flags [P.], seq 9:17, 8 bytes

That 0.040095 gap between the first packet and its ACK is the smoking gun: the receiver sat on the ACK for the full 40 ms. Note where the delay lives — the receiver's ACK timer, not your sender. You can't fix it from your side by writing faster; the peer's stack is doing the waiting.

The usual fix: TCP_NODELAY

Nagle is a socket option on the sender: TCP_NODELAY disables it outright. It's set by default in Node.js, Go (net.TCPConn with SetNoDelay(true)), Redis, and most modern servers — precisely because they knew about this trap:

c
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
bash
$ sysctl net.ipv4.tcp_slow_start_after_idle  # related: Linux waits ~1s after idle
net.ipv4.tcp_slow_start_after_idle = 1

When Nagle is actually right

Nagle is not broken for everything. For bulk, streaming workloads — video, file transfer, log shipping — Nagle reduces packet count per byte by coalescing small writes into full segments, and delayed ACK's piggybacking saves a packet per transaction. The interaction only bites when the traffic is request-response with small payloads: each write is under MSS, the response is the only piggyback opportunity, and the ACK gets parked. The decision rule:

  • Request/response with small messages (RPC, chat, HTTP/2 frames): TCP_NODELAY on.
  • Bulk one-way streaming where the peer writes nothing back: Nagle on (or better, batch writes to full segments yourself — Nagle exists to hide your laziness).

The deeper fix: stop writing small

Nagle's real lesson is that the kernel's coalescing is a proxy for your buffering. The clean architecture is: your protocol reads a whole message, then writes once — with TCP_CORK on Linux you can even tell the kernel "hold everything until I uncork", and it emits one full segment. Combined with TCP_NODELAY for interactivity, that's the idiomatic pattern: coalesce at the application boundary, never at the timer boundary.

The mental model

  • Nagle batches the sender's small writes; delayed ACK batches the receiver's acknowledgements. Each is correct in isolation; together they deadlock small messages.
  • The 40 ms isn't a network problem — it's the peer's ACK timer, and tcpdump shows it exactly once per interaction.
  • TCP_NODELAY on request-response connections is not a micro-optimization; it's the difference between "feels local" and "feels remote".
  • If you ever see a steady ~40 ms (or ~200 ms) added to every small request and your stack isn't setting TCP_NODELAY, you've found the bug without touching the network.