TCP Congestion Control

Flow control protects a slow receiver. But there is a second, larger danger, and it nearly killed the early internet. In October 1986, the link between Lawrence Berkeley Lab and UC Berkeley — a few hundred metres apart — saw its throughput collapse from 32 kbps to 40 bps, a thousandfold drop. The cause wasn't a broken cable. It was too many senders, all pushing as hard as they could, filling the routers' queues until packets were dropped, triggering retransmissions, which added more load, causing more drops. The network was drowning in its own retransmissions.

This is congestion collapse, and the fix — Van Jacobson's congestion-control algorithms, retrofitted into TCP in 1988 — is one of the most consequential pieces of engineering in the internet's history. Its job is to protect not the receiver but the network itself: to let millions of independent TCP senders, with no central coordinator, collectively find a sending rate that keeps the shared links busy but not overwhelmed.

The congestion window (cwnd)

Flow control gave the sender \text{rwnd}, a limit set by the receiver. Congestion control adds a second self-imposed limit, the congestion window \text{cwnd} — the sender's own estimate of how much the network can absorb. The sender may have at most \min(\text{cwnd}, \text{rwnd}) bytes in flight, obeying whichever guardian is stricter.

The deep problem: no router ever tells a sender "you're congesting me." The sender must infer congestion from the only signal it has — packet loss (a timeout, or a run of duplicate ACKs). So congestion control is a feedback loop that keeps probing: push the rate up gently to look for spare capacity, and back off hard the moment loss suggests the network is full. The precise dance of "up gently, down hard" is AIMD.

Slow start, then congestion avoidance

A new connection has no idea how much bandwidth is available, so it uses two phases, switched by a threshold called ssthresh (slow-start threshold):

When loss does strike, TCP reacts with multiplicative decrease: it sets \text{ssthresh} to half the current window and cuts \text{cwnd} sharply. Additive increase + multiplicative decrease = AIMD, and it traces the iconic sawtooth: a gentle linear climb, a sudden halving, climb again, halve again — forever hunting the edge of congestion.

Fast retransmit and fast recovery (TCP Reno)

Not all losses are equal. A full timeout is dire — it suggests the network went silent, so TCP resets \text{cwnd} all the way to 1 and re-enters slow start. But three duplicate ACKs tell a happier story: later segments are getting through, so only one was lost and the pipe is basically fine. TCP treats this mild signal gently:

The runnable model below iterates the AIMD state machine — slow-start doubling, additive increase, and a Reno-style halving on loss — and prints the cwnd trajectory that produces the sawtooth:

// A minimal TCP-Reno-style congestion controller: slow start, congestion // avoidance, and multiplicative decrease on a (triple-dup-ACK) loss. let cwnd = 1; // congestion window, in segments let ssthresh = 16; // slow-start threshold const LOSS_AT = 24; // pretend the network drops a packet when cwnd hits this console.log("RTT cwnd phase"); for (let rtt = 0; rtt < 18; rtt++) { const phase = cwnd < ssthresh ? "slow-start" : "cong-avoid"; console.log(`${String(rtt).padStart(3)} ${String(cwnd).padStart(4)} ${phase}`); if (cwnd >= LOSS_AT) { // loss detected via 3 dup ACKs -> multiplicative decrease (fast recovery) ssthresh = Math.floor(cwnd / 2); cwnd = ssthresh; // Reno: halve, don't crash to 1 console.log(` ** loss! ssthresh -> ${ssthresh}, cwnd halved **`); } else if (cwnd < ssthresh) { cwnd = cwnd * 2; // slow start: exponential (double per RTT) } else { cwnd = cwnd + 1; // congestion avoidance: additive (+1 per RTT) } } console.log("\nExponential ramp, then the classic +1 / halve sawtooth around capacity.");

Why AIMD is fair, and what came after

AIMD isn't just stable — it's fair. Imagine two flows sharing a link. On each additive increase both gain the same absolute amount (the "increase" line runs at 45°); on each multiplicative decrease both lose the same proportion. Trace it and the two flows' rates converge toward equal shares, regardless of where they started. This is why a room full of independent TCP connections, with no negotiation, tends to split a bottleneck roughly evenly — a small miracle of distributed control.

Classic Reno is loss-based: it treats loss as the one true congestion signal. That assumption frays on modern networks, so newer algorithms refine the picture:

Two misreadings to unlearn at once. First: loss is not a disaster — it's TCP's normal congestion signal. A loss-based protocol like Reno needs to occasionally lose a packet; that's how it discovers the ceiling and knows to back off. A connection that never lost a single packet would never learn it was under-using the link. The sawtooth's teeth are losses, and the connection is working perfectly. Panic over "packet loss detected!" usually misunderstands what TCP is doing on purpose.

Second: "slow start" is not slow. The name describes the starting point (a tiny window), not the growth rate — which is exponential, doubling every RTT. It is, if anything, the fastest-accelerating phase TCP has; "slow" refers only to beginning cautiously from a small window rather than blasting at full rate immediately.

Loss-based congestion control has a well-known flaw called bufferbloat: modern routers have huge buffers, so a loss-based sender keeps filling them — adding latency for everyone — long before any packet is actually dropped. You've felt it: a big upload that makes every other connection on your Wi-Fi laggy. BBR's bandwidth-and-delay model was designed partly to escape this by keeping queues short. The debate between loss-based (CUBIC), delay/model-based (BBR) and router-assisted (ECN — Explicit Congestion Notification, where routers mark packets instead of dropping them) approaches is still very much live research.