TCPflow controlreceive windowrwndwindow scalingzero window

Flow Control: The Receive Window

The receiver tells the sender, in every ACK, how many more bytes it has room for; the sender never has more than that in flight; when the application stops reading, the buffer fills, the window goes to zero and the sender stops — so a slow consumer throttles a fast producer all the way back through the network, which is backpressure by design.

ConceptualLinux
▶ InteractiveInterview question
Progress

The problem

A sender on a 10 Gbit/s link can produce data a thousand times faster than a receiving process that parses it line by line. Without a limit, the receiver’s buffer overflows and the excess is dropped, retransmitted, dropped again. How does the receiver say "this much and no more" — and what happens when the answer is "none"?

The receiver’s buffer is finite

Every TCP socket has a receive buffer in the kernel. Segments that arrive in order are appended to it; the application’s read() drains it. If the application reads slower than data arrives, the buffer fills. The receiver reports the free space — the receive window, rwnd — in the 16-bit window field of every segment it sends, and the sender obeys one rule: bytes sent but not yet acknowledged ≤ rwnd. This is flow control: the receiver’s capacity, advertised continuously, bounding the sender.

The window slides. As the receiver ACKs bytes, the left edge moves right, and as the application reads, the right edge moves right; the sender may transmit anything between the last ACK and the last ACK plus the window. When the application stops reading, the right edge stops and the sender runs into it. Nothing is dropped, nothing is retransmitted; the sender simply waits, and the data that is waiting sits in *its* send buffer, which then fills too, so that its write() eventually blocks. That chain — receiver’s application → receiver’s buffer → window → sender’s buffer → sender’s application — is backpressure, and TCP provides it for free.

A slow consumer throttles a fast producer
  1. Receive buffer fillsin-order bytes nobody has read
  2. Advertised window shrinks to 0carried in every ACK
  3. Sender stops transmittingunacknowledged bytes = window; nothing more may be sent
  4. Sender’s send buffer fillswrite() copies until it is full
  5. Sending application blocks in write()or gets EAGAIN on a non-blocking socket

Window scaling and the bandwidth-delay product

Linux

The window field is 16 bits, so the largest window TCP could originally advertise was 65,535 bytes. A sender may not have more than that in flight, and in flight means "sent, ACK not yet back" — a full round trip. So throughput ≤ window / RTT: 64 kB per 100 ms is 5.2 Mbit/s, no matter how fast the link. To fill a 1 Gbit/s path with a 100 ms RTT you need 1 Gbit/s × 0.1 s = 12.5 MB in flight — the bandwidth-delay product, the amount of data the path itself is holding when it is full.

The window scale option (RFC 7323) multiplies the window field by 2^n, n up to 14, allowing windows up to 1 GiB. It is negotiated only in the SYN and SYN-ACK (The Three-Way Handshake) — a middlebox that strips it, or a stack with it disabled, caps every connection at 64 kB forever, which on a long path is a few Mbit/s with no error and no obvious cause. Modern kernels autotune buffer sizes: Linux grows the receive buffer as the connection demonstrates it needs more, between the bounds in net.ipv4.tcp_rmem (default max 6 MB, often raised on servers that talk across continents).

Window as a throughput ceiling
max throughput = window / RTT

window 64 KB,   RTT 1 ms    ->  512 Mbit/s      (LAN: fine)
window 64 KB,   RTT 100 ms  ->  5.2 Mbit/s      (cross-continent: throttled)
window 1 MB,    RTT 100 ms  ->  84 Mbit/s
window 12.5 MB, RTT 100 ms  ->  1 Gbit/s        (= bandwidth-delay product)

net.ipv4.tcp_rmem = 4096 131072 6291456   # min default max (bytes), autotuned in between

Zero window and window probes

When the buffer is completely full the receiver advertises window 0. The sender must stop, and now there is a deadlock risk: the receiver will announce a reopened window in its next ACK — but ACKs are sent in response to data, and no data is arriving. If that window-update segment is lost, both sides wait forever. So the sender runs a persist timer and sends periodic zero-window probes (one byte beyond the window, at increasing intervals) purely to elicit an ACK that carries the current window. The probe does not stop after backoff; Linux keeps probing, so a zero-window connection to a stuck reader stays open indefinitely.

A related pathology is silly window syndrome: the receiver frees a few bytes, advertises a tiny window, the sender sends a tiny segment, and the connection limps along at one small packet per round trip. Both ends guard against it — the receiver does not advertise a window smaller than an MSS or half its buffer until it has that much free; the sender (Nagle) does not send tiny segments while others are unacknowledged. The combination of Nagle with delayed ACKs is the source of the classic 40 ms stalls in request/response protocols that write headers and body separately.

  • Window 0 → sender stops; persist timer → probes until the window reopens.
  • ss -ti shows rwnd_limited: — the fraction of time the sender was blocked by the receiver’s window.
  • A zero window that never reopens is an application that stopped reading, not a network fault.

The application is the receiver

Linux

The advertised window is, at bottom, a statement about the receiving *process*: how far behind it is. A server that reads a request, then does 500 ms of work before reading the next byte, has a full receive buffer and a zero window for those 500 ms as far as a streaming client is concerned. A consumer that calls read() only from a single-threaded event loop that is currently busy advertises the same. On the other side of the same coin, a *sending* process whose peer is slow ends up blocked in write() — and if that process is a server writing to a slow client, one slow client can pin one worker thread or, in an event loop that writes synchronously, stall everyone (What Happens When the Receiver Is Slow, slow-client-blocks-server).

This is the point where the OS and the network meet. Recv-Q in ss -tan is bytes in the receive buffer the application has not read; a persistently non-zero Recv-Q on a server is a server that is not keeping up. Send-Q is bytes in the send buffer not yet acknowledged; a large Send-Q toward one peer is a slow or windowed peer. The Buffer Chain follows the bytes through both queues, and the socket-buffer-full-backpressure challenge diagnoses a stall from these two numbers alone.

ss -tan: who is slow?
$ ss -tan state established
Recv-Q   Send-Q   Local Address:Port    Peer Address:Port
0        0        10.0.0.5:8080         10.0.0.9:51820     # healthy
524288   0        10.0.0.5:8080         10.0.0.11:49200    # WE are not reading: our app is behind
0        3145728  10.0.0.5:8080         10.0.0.14:50111    # THEY are not reading: peer window / path

$ ss -ti dst 10.0.0.14 | grep -o 'rwnd_limited:[^ ]*'
rwnd_limited:41.3s(92.0%)                                  # blocked on the peer's window 92% of the time

Flow control versus congestion control

Two windows, two purposes, one sender. The receive window is set by the receiver and protects the receiver’s buffer. The congestion window (Congestion Control: Protecting the Network) is computed by the sender and protects the network’s queues. The sender obeys both: in flight ≤ min(rwnd, cwnd). A connection can be receiver-limited, congestion-limited, or — the good case — application-limited, sending everything it has as soon as it has it. ss -ti reports which (rwnd_limited, sndbuf_limited, and cwnd against the bandwidth-delay product).

They fail differently too. Receiver-limited throughput is flat and steady, capped at rwnd/RTT, with no loss; congestion-limited throughput has the sawtooth of growth and loss. Knowing which one you are looking at is the first step of every "the transfer is slow" investigation.

Key points

  • The receiver advertises its free buffer space (rwnd) in every segment; the sender keeps unacknowledged bytes ≤ rwnd.
  • Throughput ≤ window / RTT: a 64 kB window is 5 Mbit/s at 100 ms; window scaling (SYN-only option) and autotuning lift the cap.
  • Window 0 stops the sender; zero-window probes keep the connection alive and detect the reopening.
  • A slow-reading application is what a zero window means; backpressure propagates to the sender’s write().
  • Recv-Q = we are behind; Send-Q = they are behind (or the path is); rwnd_limited quantifies it.
  • Flow control protects the receiver; congestion control protects the network; in flight ≤ min(rwnd, cwnd).

Why does this exist?

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

Why let the receiver throttle the sender instead of dropping excess?

Dropping would trigger retransmission and congestion reduction for a problem that has nothing to do with the network; advertising the window converts a receiver’s slowness into a sender pause with zero wasted transmissions.

Why is the window field only 16 bits?

In 1981 a 64 kB window was enormous and buffers were expensive; the scale option was retrofitted in the SYN because that is the only place both sides can agree on a multiplier before any window value is interpreted.

Why does backpressure matter to me as an application developer?

Because it is the only thing standing between a fast producer and unbounded memory growth; frameworks that ignore it (reading everything into a queue "to be responsive") reproduce the overflow TCP prevented, one layer up.

Flow control: the receive window

Flow control: the receiver sets the pace
Every ACK advertises how much buffer the receiver has left. The sender may never have more than that in flight.
Receive buffer fill (64 kB)8 kB
Advertised window rwnd56 kB
flowingticks 10–13: the app stops calling read()
t=0: send 12 kB; ◀ ACK rwnd=52 kB
A slow consumer is not an error: a service whose handler reads slowly from the socket fills its kernel buffer, the window closes, and the upstream client blocks in write(). Latency rises hop by hop, back to the origin — and no log line says why.
1/24 · t=0Simulated

How it fails

What the failure looks like from inside real software.

  • Cross-continent transfers stuck at ~5 Mbit/s: window scaling stripped or disabled; ss -ti shows wscale:0,0 and rwnd_limited near 100%.
  • A server with growing Recv-Q on many connections: its threads are blocked on something else and it has stopped reading; clients see stalls and eventually their own send buffers fill.
  • One slow client, one blocked worker: the server writes a large response synchronously, the client reads slowly, the worker sits in write() for a minute, and the pool shrinks by one.
  • A proxy buffering an entire upload in memory "for simplicity": it reads without limit, the receiver’s window never closes, and memory grows with the size of the upload.
  • Nagle plus delayed ACK in a request/response protocol: 40 ms added to every exchange, invisible until someone measures per-call latency and finds a suspicious constant.