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.
- When the sender is blocked by a zero window, it starts a persist timer.
- On expiry, it sends a tiny window probe — a segment of one byte — forcing the
receiver to reply with a current ACK that re-advertises its window.
- If the window is truly open now, the probe's ACK reveals it and the sender resumes; if still zero,
the sender waits and probes again. The deadlock is impossible.
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:
-
Flow control protects the RECEIVER. It stops a fast sender from overrunning a slow
receiver's buffer. Its signal is the explicit \text{rwnd} the receiver
advertises. It is a two-party, end-to-end conversation; the network in between is irrelevant to it.
-
Congestion control protects the NETWORK. It stops all the senders collectively
from overwhelming the routers and links between them. Its signal is implicit — packet loss
or delay — and its variable is \text{cwnd}, which TCP computes on its own.
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.