0%
Networking for Backend Engineers
The Stack
Names and Trust
Application Protocols
Getting Traffic to a Server
Performance
Flow Control and Congestion Control
TCP has two independent brakes and conflating them is the most common conceptual error in this area. Flow control protects the receiver from being overwhelmed and is advertised explicitly in every acknowledgement. Congestion control protects the network from being overwhelmed and is inferred by the sender from loss and delay. A transfer is limited by whichever is smaller at any moment, and the two have entirely different diagnoses and fixes.
The receive window is a promise about buffer space
Every TCP segment carries a 16-bit window field stating how many more bytes the sender may transmit beyond what has already been acknowledged. It is a promise about the receiver's free buffer space, updated on every acknowledgement, and the sender must respect it absolutely: unlike congestion control, which is an estimate the sender makes, the receive window is an instruction.
The sender's in-flight data is therefore bounded by the minimum of two quantities, and knowing which one is binding is the whole diagnostic question of this lesson:
Scaled by the window scale option agreed in the handshake, the advertised window can reach about a gigabyte, which is enough for any realistic path. Without that option it is capped at 65,535 bytes, which as the previous lesson showed caps long-path throughput severely.
Where the window comes from
The advertised window is the free space in the receive buffer, so it is the buffer size minus whatever the application has not yet read. This gives the window its most useful property as a diagnostic: it is a direct measurement of how well the receiving application is keeping up.
On Linux the buffer is autotuned between the three values in net.ipv4.tcp_rmem, and the kernel grows it when a connection demonstrates it can use more. The growth is based on the measured bandwidth-delay product, which means a connection on a fast long path gets a large buffer and a connection to a neighbour gets a small one, with no configuration.
Two things disable this. An explicit SO_RCVBUF pins the buffer and switches autotuning off for that socket, which is why a library that sets it is a recurring cause of connections that will not reach line rate. And net.ipv4.tcp_moderate_rcvbuf set to zero disables autotuning globally, which appears in a few old tuning guides and should be reversed wherever it is found.
What the window does not tell you
The receive window measures buffer space, not the receiver's ability to do anything useful with the data, and conflating the two causes a specific class of outage worth naming.
A receiver can advertise a large, healthy window while being completely unable to keep up, as long as something between the socket and the work is willing to buffer. This happens constantly in modern asynchronous frameworks: an asynchronous handler reads from the socket into an unbounded in-process queue, which empties the kernel buffer promptly, which keeps the advertised window wide open, which tells the sender to keep sending. The flow control signal is intact and it is lying. The backlog has simply moved from a bounded kernel buffer, where it would have slowed the sender down, into an unbounded heap allocation, where it grows until the process is killed by the out-of-memory killer.
The giveaway is the shape of the failure. Memory climbs steadily under load, the connection's Recv-Q stays near zero, the advertised window never shrinks, and the process dies without a single network symptom. Every network metric says the path is healthy, because the path is healthy; the queue is inside the application.
The rule that prevents it is that backpressure has to be continuous from the slowest stage all the way back to the socket. If the work queue is bounded and the reader stops reading when the queue is full, the kernel buffer fills, the window closes, and the sender is told to stop, which is exactly the behaviour TCP offers for free. If any stage between them is unbounded, the chain is broken at that stage and TCP's flow control is disabled as surely as if it had been turned off.
In practice this means three things when reviewing a consumer. The read loop must be driven by the downstream consumer's readiness rather than by socket readiness. Every intermediate buffer needs an explicit maximum, chosen from how much delay you are willing to accept rather than from how much memory is available. And the condition of a full internal queue needs to be visible, because by the time memory growth is noticed the cause is several minutes old.
The same reasoning applies in the other direction on the sending side. A sender that writes into a socket whose window has closed blocks, or gets a short write, or gets EAGAIN, and those are the mechanisms by which a slow receiver slows a fast sender. Code that treats a short write as an error, retries it in a loop, or buffers the remainder in the application has again moved the queue inward and defeated the mechanism.
Zero window, and what it proves
When the receive buffer fills because the application is not reading, the receiver advertises a zero window, and the sender must stop. It does not retransmit, it does not back off congestion control, it simply waits.
To avoid deadlock if a window update is lost, the sender sends periodic zero window probes, starting after a few hundred milliseconds and backing off exponentially. The receiver answers each with its current window, which is zero until the application reads something. When the application finally reads, a window update segment is sent carrying the newly available space, and transmission resumes.
This sequence is one of the most valuable things to find in a capture, because it assigns responsibility unambiguously. Zero window advertisements from the receiver mean the data arrived, the network delivered it, and the receiving application has not consumed it. No network change, no congestion control choice, no buffer tuning will help: the fix is in the receiving application's read loop, its thread pool, or whatever it does synchronously per message. The equivalent reading without a capture is a nonzero and persistent Recv-Q in ss output on the receiving side.
Silly window syndrome and the two algorithms that prevent it
A naive implementation can degenerate into exchanging tiny segments. If the application reads 10 bytes at a time, the receiver could advertise a 10-byte window, the sender could send a 10-byte segment carrying 40 bytes of header, and the connection would spend 80 percent of its capacity on headers. This is silly window syndrome, and two mechanisms prevent it from opposite ends.
On the receiver, the rule is to advertise no window increase until it is worth announcing, meaning at least one maximum segment size or half the buffer. Small increments are hidden until they accumulate.
On the sender, Nagle's algorithm holds a small write until either a full segment accumulates or all previously sent data is acknowledged. It is the right default for a protocol that writes in small pieces and the wrong default for request-response latency, which is why TCP_NODELAY exists and why the 40-millisecond stall from the previous lesson is the symptom of leaving it unset on an interactive socket.
Application limited is the usual answer, and it is not a network problem
Before investigating a transport for a disappointing transfer rate, it is worth checking whether the sender ever gave the transport enough data to work with. A connection is application limited when the application is not writing fast enough to fill the smaller of the two windows, and this is extremely common: a process reading from disk one record at a time, a serialisation step in the hot path, or a loop that writes 4 KB and then does work, will all produce a transfer that looks slow and is not the network's fault.
The evidence is specific. In ss -tni, an application-limited connection shows a congestion window that is large or growing, zero or negligible retransmissions, and a Send-Q that is empty most of the time, which together say the transport has capacity the application is not using. The kernel also tracks this directly, and TcpExtTCPWqueueTooBig and the app_limited flag exposed through tcp_info on recent kernels make it explicit. Conversely a connection with a full Send-Q and a small congestion window is network limited, and that is where the rest of this lesson applies.
Before tuning anything, establish which of three things is limiting the transfer. A persistent Recv-Q on the receiver means the receiving application is the bottleneck. A persistent Send-Q on the sender with a small congestion window means the network is the bottleneck. An empty Send-Q with a healthy congestion window means the sending application is the bottleneck. These three readings take one command and they point at three completely different teams, which is why taking them first is worth more than any sysctl.
Flow control in the protocols above TCP
The pattern recurs at every layer, which is worth noticing because the same reasoning applies. HTTP/2 has its own per-stream and per-connection flow control windows with WINDOW_UPDATE frames, precisely because many streams share one TCP connection and one stream's slow reader must not stall the others. gRPC inherits that. QUIC has per-stream and connection-level limits for the same reason. Message brokers have prefetch limits and credit schemes that do the same job at the application level.
In every case the question to ask when something stalls is the same one: whose buffer is full, and who is supposed to drain it. A stalled HTTP/2 stream with a zero flow control window is the same situation as a zero TCP window, one layer up, and it is diagnosed by looking at the consumer rather than the path.
Loading problem...