Frontend Labs
⋯

Part IX — TCP flow and congestion control

Two independent limits govern how much unacknowledged data a TCP sender may have "in flight":

bytes_in_flight  <=  min( rwnd , cwnd )

rwnd  = receiver's advertised window  -> FLOW control   (don't overrun the peer's buffer)
cwnd  = sender's congestion window    -> CONGESTION ctrl (don't overrun the network)

Flow control is a hard contract from the receiver. Congestion control is the sender's own estimate of what the path can take.

30. Flow control: the sliding window, window scaling, and SWS

30.1 The sliding window

The receiver advertises rwnd in every ACK = free space in its socket receive buffer. The sender may send up to snd_una + min(rwnd, cwnd) — i.e. bytes from "oldest unacked" forward. As ACKs come in, the window slides right.

   sent+acked | sent, not yet acked |  may send now  | can't send yet
 --------------+---------------------+----------------+---------------->
             snd_una              snd_nxt        snd_una + wnd
              <-------- window (min(rwnd,cwnd)) -------->

If the app on the receiver stops calling read(), its buffer fills, rwnd shrinks toward 0, and the sender throttles — automatically. This is why a slow consumer naturally slows a fast producer over TCP ("backpressure for free").

30.2 Window scaling — the 64 KB wall

The header's Window field is 16 bits → max 65 535 bytes. Max throughput is window / RTT:

RTT Without scaling (64 KB) Needed window for 1 Gbit/s
1 ms (same rack) 512 Mbit/s 125 KB
30 ms (regional) 17 Mbit/s 3.75 MB
100 ms (intercontinental) 5 Mbit/s 12.5 MB
300 ms (satellite) 1.7 Mbit/s 37.5 MB

Window Scale option (SYN only): a shift count s (0–14); the real window is advertised << s. With s=7, a 65 535 advertisement means ~8 MB. Both SYNs must carry it or scaling is off for the whole connection. Linux enables it by default (net.ipv4.tcp_window_scaling=1); the actual max is bounded by net.ipv4.tcp_rmem / tcp_wmem and autotuning (Ch. 35).

Middleboxes that strip or rewrite the WScale option cause a nasty asymmetry: one side scales, the other doesn't, and windows are misinterpreted by orders of magnitude → stalls or resets.

30.3 Zero window and the persist timer

If the receiver's buffer is completely full it advertises window = 0. The sender stops. When the app drains the buffer, the receiver sends a window update ACK. But ACKs are not retransmitted — if that update is lost, both sides wait forever. The persist timer prevents deadlock: the sender periodically sends a 1-byte zero-window probe; the receiver responds with the current (possibly still 0) window. Eventually the real window is learned.

Seeing frequent TCP ZeroWindow / TCP Window Full in Wireshark = the receiving application is too slow (not reading fast enough). The network is fine; fix the consumer (more worker threads, faster processing, bigger buffer).

30.4 Silly Window Syndrome (SWS)

Pathology where the window is repeatedly advanced by tiny amounts, so the connection exchanges many small segments (huge header overhead). Two guards:

  • Receiver side (David Clark's fix): don't advertise a larger window until it can grow by at least one MSS (or half the buffer).
  • Sender side (Nagle's algorithm, Ch. 33): don't send a small segment while a previous small segment is still unacked; coalesce.

30.5 Buffer autotuning

Modern Linux dynamically sizes each connection's receive buffer between tcp_rmem min/default/max based on the measured bandwidth-delay product (tcp_moderate_rcvbuf=1). You usually shouldn't set SO_RCVBUF/SO_SNDBUF manually — doing so disables autotuning and often hurts. Only raise the tcp_rmem/tcp_wmem max (Ch. 35, 37) for long-fat-network workloads.

30.6 War story

A log shipper's throughput to a central collector was stuck at ~90 Mbit/s despite a 10G network, RTT ~25 ms. ss -tim showed rcv_space/rwnd pinned near 3 MB — the collector app had called setsockopt(SO_RCVBUF, 2MB) "to bound memory," which capped and froze the window and disabled autotuning. 3MB / 0.025s ≈ 960 Mbit/s ceiling, minus overhead ≈ observed. Removing the manual SO_RCVBUF let autotuning grow it to ~25 MB and throughput hit multi-Gbit/s. Lesson: manual socket buffer sizes usually do more harm than good; tune the sysctl ceilings and let autotuning work.

Try it yourself

ss -tim dst <peer>       # rcv_space, snd_wnd, rcv_wnd, mss, cwnd, bytes_acked...
sysctl net.ipv4.tcp_window_scaling net.ipv4.tcp_moderate_rcvbuf
sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem
nstat -az | grep -Ei 'TCPWantZeroWindow|TCPZeroWindow|TCPBacklogDrop|PruneCalled'
# Wireshark filter: tcp.analysis.zero_window || tcp.analysis.window_full

Generated from real-life-example.md · 8 Oct 2026 16:36