TCP flow control answers one question: how much data may the sender have in flight before the receiver says “stop”? It exists because the two ends run at different speeds. A server can push data far faster than a phone app on a busy CPU can process it, and the bytes have to wait somewhere. Flow control makes sure they wait in a buffer that has room, instead of being dropped and resent.
It’s often confused with congestion control, and interviewers ask about the two together for that reason. We’ll get to the difference after seeing flow control work.
The receive window, in one paragraph
Every TCP receiver has a buffer for bytes that have arrived but haven’t been read by the application yet. Each ACK it sends carries a window field saying how many more bytes it can accept right now. The sender keeps track of how many bytes it has sent that aren’t acknowledged yet, and never lets that exceed the advertised window. When the application reads, buffer space frees up and the next ACK advertises a larger window. When the application stops reading, the window shrinks towards zero, and at zero the sender must stop.
So the window slides: it’s anchored at the last acknowledged byte, and its size tracks the receiver’s free space.
Stalling a receiver on purpose
Here’s a Node script with a server that accepts a connection and then doesn’t read from it for three seconds, while the client tries to write 200 chunks of 64 KiB (about 13 MB):
import net from 'node:net';
import { execSync } from 'node:child_process';
const server = net.createServer((sock) => {
sock.pause(); // the application stops reading
setTimeout(() => {
let got = 0;
sock.on('data', (d) => { got += d.length; });
sock.on('end', () => { console.log(`server: received ${got} bytes`); server.close(); });
sock.resume();
}, 3000);
});
server.listen(9100, '127.0.0.1', () => {
const client = net.connect(9100, '127.0.0.1', () => {
const chunk = Buffer.alloc(64 * 1024);
let written = 0, calls = 0;
const pump = () => {
while (calls < 200) {
calls++; written += chunk.length;
if (!client.write(chunk)) return; // stop until 'drain'
}
client.end();
};
pump();
client.on('drain', pump);
setTimeout(() => {
console.log(`client: wrote ${written} bytes, ${client.writableLength} still queued in Node`);
console.log(execSync("netstat -an -p tcp | grep 9100 | grep ESTABLISHED").toString().trim());
}, 1000);
});
});One second in, on macOS (the exact byte counts change slightly from run to run):
client: wrote 1114112 bytes, 65536 still queued in Node
tcp4 604492 0 127.0.0.1.9100 127.0.0.1.54534 ESTABLISHED
tcp4 0 433708 127.0.0.1.54534 127.0.0.1.9100 ESTABLISHED
server: received 13107200 bytesRead the two netstat rows as the two ends of the same connection. The first is the server’s socket: 604,492 bytes sit in its receive queue (Recv-Q), delivered by TCP but not read by the paused application. Its receive window is effectively closed. The second row is the client’s socket: 433,708 bytes in its send queue (Send-Q), accepted by the kernel from write() but not acknowledged, because the receiver has no room. And above the kernel, Node itself is holding another 64 KiB in user space.
That’s the chain of backpressure, from the slow reader back to the fast writer:
- The receiving application stops reading, so the receive buffer fills and the advertised window drops to zero.
- The sender’s kernel can’t transmit, so its send buffer fills.
- With the send buffer full, a blocking
write()would block. Node uses non-blocking sockets, so it queues the data itself andwrite()starts returningfalseonce its queue passes the stream’shighWaterMark(64 KiB by default in Node 22). - The client loop respects that and waits for
'drain'.
After three seconds the server resumes, the window reopens, data flows, 'drain' fires, and all 13,107,200 bytes arrive. Nothing was lost; the sender just waited.
The mistake interviewers listen for is in step 4. Code that ignores the return value of write() keeps queuing into Node’s memory, and a fast producer feeding a slow client grows the process’s heap until it crashes. Flow control protected the network and the receiver; it can’t protect you from buffering unbounded data in your own process. Streams with pipe() or pipeline() handle this for you.
Zero windows and the persist timer
When the window hits zero, the sender stops, but it can’t just wait for an ACK forever, because the window-update ACK from the receiver might be lost. So TCP starts a persist timer and periodically sends a tiny zero-window probe. The receiver’s reply carries the current window, and once it’s non-zero the transfer resumes. In a packet capture you’ll see Wireshark label these TCP ZeroWindow and TCP ZeroWindowProbe. Seeing them in production is a strong hint that the receiving application is the bottleneck, not the network.
A related historical problem is silly window syndrome: a receiver that reads one byte at a time advertises one byte of window, and the sender dutifully sends one-byte segments with 40 bytes of headers each. Modern stacks avoid it on both sides, with receivers holding back small window updates and senders (via Nagle’s algorithm) avoiding tiny segments.
The window caps throughput on long links
A sender can have at most one window of unacknowledged data in flight, and acknowledgements take a round trip to come back. So a single connection’s throughput is at most:
throughput ≤ window / RTTThe original TCP header has a 16-bit window field, so the largest window is 65,535 bytes. On a 200 ms path, that’s 65,535 / 0.2 = 327,675 bytes per second, about 2.6 Mbit/s, no matter how fast the link is. To fill a link you need a window at least as large as the bandwidth-delay product: 1 Gbit/s × 0.2 s = 25 MB in flight.
The window scale option (RFC 7323) fixes the header limit. Both ends announce a shift count in their SYN packets, and the window field is multiplied by 2 to that power, up to a maximum of about 1 GiB. Two details matter in practice. The option is only exchanged during the handshake, so if a middlebox strips it from the SYN, the connection is stuck with 64 KiB windows for its lifetime. And the actual buffer sizes come from the OS. macOS starts at 128 KiB (net.inet.tcp.recvspace: 131072) and autotunes up to 4 MiB (net.inet.tcp.autorcvbufmax: 4194304); Linux uses net.ipv4.tcp_rmem. On Linux, an application that sets SO_RCVBUF explicitly turns off receive autotuning for that socket, which is a common way to accidentally cap throughput on fast, distant links.
Flow control is not congestion control
Both limit how much the sender has in flight, which is why they get mixed up. The difference is who they protect.
- Flow control protects the receiver. The window (
rwnd) is advertised explicitly by the other end, in every ACK. - Congestion control protects the network. The congestion window (
cwnd) is the sender’s own estimate of how much the path can carry, grown during slow start and cut when loss or delay signals congestion (RFC 5681 for the classic algorithms; CUBIC and BBR are common today). Nobody advertises it.
The sender uses whichever is smaller. A transfer to a slow phone is usually flow-control limited; a transfer across a congested link is congestion-control limited. Diagnosing slow throughput starts with working out which of the two is in charge. On Linux, ss -ti prints both for each connection (cwnd, rcv_space, wscale), which makes it the first tool to reach for.
Interview exercise: the slow cross-region copy
A nightly job copies backups from a server in Mumbai to one in Virginia over a dedicated 1 Gbit/s link. The RTT is about 200 ms. Each copy runs at almost exactly 2.6 Mbit/s, and the link shows no loss. Why, and what would you check?
Answer and reasoning
2.6 Mbit/s at 200 ms is 65,535 bytes per round trip, the largest window without scaling. With no loss, congestion control isn’t holding it back, so the window is: the connection is flow-control limited at 64 KiB.
I’d check three things. First, whether window scaling was negotiated: capture the SYN and SYN-ACK with tcpdump and look for the wscale option, or read wscale in ss -ti on Linux. A firewall or old load balancer stripping TCP options is a classic cause. Second, whether the copy tool sets a small SO_RCVBUF or SO_SNDBUF, which on Linux disables autotuning and pins the buffer. Third, the OS maximums (net.ipv4.tcp_rmem and tcp_wmem), which need to allow roughly the bandwidth-delay product of 25 MB.
Fixing the window should raise one stream’s throughput by orders of magnitude. Running several streams in parallel would also help, but it only works around the cap, and it’s worth saying so: each extra stream multiplies the in-flight data without fixing why one stream is stuck.