Flow Control and the Sliding Window

Picture pouring water from a fire hose into a teacup. The hose is a beefy server on a fast link; the teacup is a tiny IoT sensor, or a phone that's busy and slow to empty its buffer. If the fast sender blasts data as quickly as it can, the slow receiver's buffer overflows, and every byte that arrives with nowhere to go is simply dropped — forcing wasteful retransmissions of data the network already carried. The receiver needs a way to say "slow down, I'm full."

That mechanism is flow control, and it is one of TCP's quiet essentials. It is a conversation strictly between the two endpoints about the receiver's capacity — and it must not be confused with its famous cousin, congestion control, which is about the network's capacity. Same idea (send slower), utterly different problem.

The receive window (rwnd)

Every TCP receiver keeps a buffer for bytes that have arrived but the application hasn't yet read. The amount of free space left in that buffer is the receive window, \text{rwnd}. The elegant part: the receiver advertises this number back to the sender in the window field of every ACK it sends. There's no separate protocol — flow control piggybacks on the acknowledgements that are flowing anyway.

The rule the sender obeys is simple. The amount of unacknowledged data in flight must never exceed the receiver's advertised window:

\text{LastByteSent} - \text{LastByteAcked} \;\le\; \text{rwnd}

As the receiving application drains its buffer, free space opens up, \text{rwnd} grows, and later ACKs advertise the larger value — permitting the sender to speed back up. As the buffer fills, \text{rwnd} shrinks toward zero, throttling the sender. The receiver is, in effect, continuously handing the sender a permission slip that says exactly how much more it may send.

The sliding window in motion

The "window" is a range of byte-sequence numbers the sender is allowed to have in flight. As ACKs arrive, its left edge advances (acknowledged bytes leave the window); as the advertised \text{rwnd} permits, its right edge advances (new bytes may be sent). The window slides forward over the byte stream — hence the name. Data conceptually falls into four zones: sent-and-acked, sent-but-unacked (in flight), allowed-to-send-now, and can't-send-yet. The demo animates the window sliding as ACKs come back:

// A sliding window over a byte stream. The window may hold up to `rwnd` // unacknowledged bytes; as ACKs arrive its left edge advances, and the // right edge follows so the window stays the advertised size. const stream = 20; // total bytes to send (labelled 0..19) let rwnd = 6; // receiver advertises room for 6 bytes let base = 0; // LastByteAcked + 1: left edge of the window let nextSeq = 0; // next byte to send function draw(justAcked: number | null) { let row = ""; for (let b = 0; b < stream; b++) { if (b < base) row += "."; // acked, gone else if (b < nextSeq) row += "*"; // in flight (sent, unacked) else if (b < base + rwnd) row += "_"; // allowed to send now else row += "x"; // outside window: can't send yet } const tag = justAcked === null ? "start" : `ACK up to ${justAcked}`; console.log(`[${row}] base=${base} rwnd=${rwnd} (${tag})`); } console.log("legend: . acked * in-flight _ sendable x blocked\n"); draw(null); // Send everything the window currently allows. while (nextSeq < base + rwnd && nextSeq < stream) nextSeq++; draw(null); // Receiver ACKs a few bytes at a time; window slides right each time. for (const ackedUpTo of [2, 4, 7, 11, 15, 19]) { base = ackedUpTo + 1; // left edge advances while (nextSeq < base + rwnd && nextSeq < stream) nextSeq++; // fill the window draw(ackedUpTo); } console.log("\nThe window of '_'/'*' slides rightward as ACKs free up space.");

The effective sending window is actually bounded by two limits at once — the receiver's \text{rwnd} and the network's congestion window \text{cwnd} (from congestion control). TCP always respects the smaller of the two:

\text{SendWindow} = \min(\text{cwnd},\ \text{rwnd})

Whichever bottleneck is tighter — the receiver being slow, or the network being congested — wins. Flow control sets \text{rwnd}; congestion control sets \text{cwnd}; TCP obeys both.

The zero-window deadlock (and its fix)

Suppose the receiver's buffer fills completely: it advertises \text{rwnd}=0, and the sender dutifully stops. Later, the application reads the buffer, freeing space — so the receiver sends a fresh ACK advertising a bigger window. But what if that ACK is lost? Now we have a classic deadlock: the sender is waiting for a window update that will never arrive, and the receiver thinks it already sent one and is waiting for data. Both sides wait forever.

A related pathology: if a slow receiver frees just one byte at a time and advertises a one-byte window each time, the sender ends up shipping tiny one-byte segments — 40 bytes of header to carry a single byte of data. This is silly window syndrome, and TCP fights it from both ends: the receiver refuses to advertise a tiny window until a worthwhile chunk of space is free, and the sender uses Nagle's algorithm to coalesce small writes, holding back a tiny segment until either an ACK arrives or enough data accumulates to fill one. Both are about keeping segments efficiently large.

How big should the window be? The bandwidth-delay product

To keep a link fully utilised, the window must be large enough to hold an entire round trip's worth of data "in the pipe" at once — otherwise the sender stalls waiting for ACKs, exactly the stop-and-wait problem in miniature. That target size is the bandwidth-delay product (BDP):

\text{BDP} = \text{bandwidth} \times \text{RTT}

Think of the network as a pipe: bandwidth is its cross-sectional area, RTT is its length, so their product is its volume — how many bytes fit inside it in flight. For a 100 Mbps link with a 100 ms RTT, that's 10^8 \times 0.1 = 10^7 bits ≈ 1.25 MB. If the window is smaller than the BDP, you can't fill the pipe and throughput suffers, no matter how fat the link. This is why TCP has a window scale option: the plain 16-bit window field maxes out at 65535 bytes, far too small for modern "long fat networks," so the option multiplies it up to gigabytes.

These two are endlessly conflated because both make TCP "send slower," but they solve completely different problems and use different variables:

A receiver can advertise a huge \text{rwnd} (plenty of buffer) while the network is badly congested, or a fast, empty network can still be throttled by a slow receiver. That's exactly why the effective window is \min(\text{cwnd}, \text{rwnd}) — TCP has to satisfy both guardians at once. Keep them separate in your head and half of TCP's behaviour suddenly makes sense.