TCPcongestion controlcwndslow startcongestion avoidanceAIMD

Congestion Control: Protecting the Network

No router tells a sender how much capacity is left, so the sender probes: it keeps a congestion window that grows exponentially, then linearly, and shrinks sharply when loss or an ECN mark says a queue is full — the sawtooth that shares links fairly, and the mechanism that CUBIC and BBR each implement with different signals.

ConceptualEducational modelLinux
▶ InteractiveInterview question
Progress

The problem

Flow control protects the receiver, but between sender and receiver are a dozen routers whose queues fill when everyone sends at once, and none of them will tell the sender how much room there is. In 1986 the NSFnet backbone’s throughput fell by a factor of a thousand for exactly this reason. How does a sender find the rate the network can carry — and share it with a thousand other senders it has never met?

The network has no window field

The receiver can say "I have 1 MB of room" (Flow Control: The Receive Window); the path cannot. Its capacity depends on the slowest link and on how many other flows are sharing it right now, and both change by the second. Routers keep queues to absorb bursts; when a queue is full they drop. That drop is the only message the network sends, and it sends it late — after the queue is already full and every packet in it is already delayed.

Congestion collapse is what happens without control: senders see loss, retransmit at the same rate, the queues stay full, more is dropped, and the network carries mostly retransmissions of retransmissions. Van Jacobson’s 1988 fix, still the skeleton of everything since, was to make every sender maintain a second window — the congestion window, cwnd — estimated from the network’s behaviour, and to send no more than min(cwnd, rwnd). Congestion control is therefore distributed, cooperative and inferred: every endpoint runs the same algorithm on the same weak signal, and the result approximates a fair share.

Slow start, congestion avoidance, and the sawtooth

Educational model

A new connection knows nothing about the path, so it starts small and grows fast. Slow start (badly named — it is the fast phase) begins with an initial window of 10 segments (~14 kB; RFC 6928, Linux default) and adds one segment to cwnd for every ACK received, which doubles cwnd every round trip: 10, 20, 40, 80… until either loss occurs or cwnd reaches the slow-start threshold, ssthresh. Above ssthresh, congestion avoidance adds roughly one segment per round trip — additive increase — probing gently for more.

On a loss event the sender infers the bottleneck queue overflowed, sets ssthresh to a fraction of the current cwnd, and cuts cwnd — to half in classic Reno, to 70% in CUBIC — then resumes additive increase. Additive increase, multiplicative decrease (AIMD) produces the famous sawtooth of cwnd over time, and it has a proof behind it: multiple AIMD flows sharing a bottleneck converge toward equal shares, because a flow with a bigger window loses proportionally more on each cut. An RTO, as opposed to a fast-retransmit loss, is treated as evidence of severe congestion and resets cwnd to one segment with slow start from scratch (Packet Loss: Duplicate ACKs, Fast Retransmit and the RTO).

cwnd over time, one flow, loss-based AIMD (educational; segments per RTT)
RTT:    1    2    3    4    5    6    7    8    9   10   11   12   13   14   15   16
cwnd:   10   20   40   80  160  L->  112  113  114  115  116  L->  81   82   83   84
        |--- slow start (x2 / RTT) ---|   |- congestion avoidance (+1 / RTT) -|
        L = loss event: cwnd x 0.7 (CUBIC-style), ssthresh = new cwnd
throughput ~= cwnd x MSS / RTT; the sawtooth's average is what you get

Loss as a signal, and why that is a compromise

Loss is a bad signal in two directions. It is late: a drop happens only after the bottleneck queue is full, and a full queue means every packet through it is delayed by the queue’s length — a loss-based sender therefore *operates at maximum latency by design*, filling whatever buffer the router has before it learns to slow down. It is also ambiguous: a corrupted Wi-Fi frame is not congestion, but a loss-based sender halves its window anyway, which is why TCP over lossy wireless was so poor for so long.

The late signal is amplified by bufferbloat: router and modem buffers sized in seconds rather than milliseconds, into which a loss-based flow expands until the queue is full, at which point every other packet on the link — a DNS query, a game packet, an SSH keystroke — waits behind a second of bulk data. The remedies are on both sides. In the network, active queue management (CoDel, fq_codel, PIE) drops or marks packets when queueing *delay* grows, before the buffer is full. In the packet, Explicit Congestion Notification (ECN): a router with AQM sets a mark in the IP header instead of dropping, the receiver echoes it in the TCP header (ECE), and the sender reduces its window as if for loss but without losing anything. ECN needs both ends and the path to cooperate; it is widely negotiated but unevenly honoured, and its low-latency successor (L4S) is being deployed.

CUBIC and BBR: two answers, different signals

Conceptual

CUBIC (Linux default since 2.6.19, also Windows and macOS) is loss-based AIMD refined for large windows: after a loss it grows cwnd along a cubic curve — fast at first, flattening as it approaches the window size where the last loss happened, then accelerating past it — so that a high-bandwidth, high-RTT path recovers in seconds rather than the minutes Reno’s one-segment-per-RTT would take. It still fills the bottleneck buffer and still reads all loss as congestion.

BBR (Google, 2016; Linux since 4.9) does not use loss as its primary signal. It continuously estimates two quantities — the bottleneck bandwidth (the maximum delivery rate observed) and the minimum round-trip time (the RTT with empty queues) — and paces sends at roughly their product, the bandwidth-delay product, deliberately keeping the queue near empty. Random loss barely affects it; bufferbloat is avoided rather than caused. The cost is fairness: early BBR could starve CUBIC flows sharing a link, and BBRv2/v3 add loss and ECN as secondary signals to coexist. QUIC stacks ship their own controllers (usually CUBIC or BBR variants) in user space, which is why they can change per application.

Do not treat either as "the" TCP. Which controller a host runs (sysctl net.ipv4.tcp_congestion_control) is a per-host choice, changes the shape of throughput under loss and delay, and is one of the few knobs worth knowing about when a service talks across long, lossy paths.

Loss-based and model-based congestion control
CUBICBBR
Primary signalpacket loss (and ECN)measured delivery rate and min RTT
Steady-state queuefull — operates at the loss pointnear empty — operates at the BDP
Random (non-congestion) losswindow cut on each losslargely ignored
Bufferbloatcauses itavoids it
Fairness with otherswell understoodimproved in v2/v3; v1 could dominate
WhereLinux default; Windows; macOSopt-in on Linux; Google, YouTube, many CDNs

What it means for your request

Linux

Slow start is why a new connection cannot use the bandwidth you paid for. From an initial window of 10 segments (~14 kB), a response of 100 kB needs about four round trips just to deliver the data: 14 kB, then 28, then 56, then the rest — on a 100 ms path, 400 ms of transfer for a file that the link could move in a millisecond. Small responses fit in the initial window and cost one RTT; that boundary is why the first ~14 kB of a web page are special and why TLS certificates and critical CSS are kept under it.

It is also why connections are reused (Keep-Alive and Connection Reuse, Connection Pooling): an established connection has already found its window. With one caveat — after an idle period longer than one RTO, Linux resets cwnd to the initial window (net.ipv4.tcp_slow_start_after_idle = 1, the default), so a pooled connection that has been idle for a second starts over. Servers that stream large responses on long-lived connections often turn this off. The same slow-start cost, repeated per request across an ocean, is the "the server is idle and the request still takes 800 ms" of Where the Time Goes: The Request Timeline.

Key points

  • The network never advertises capacity; the sender infers it from loss, ECN marks, or measured delivery rate and keeps a congestion window; in flight ≤ min(cwnd, rwnd).
  • Slow start doubles cwnd per RTT from an initial window of 10 segments; congestion avoidance adds ~1 segment per RTT; loss multiplies cwnd down — the AIMD sawtooth.
  • AIMD converges toward fair sharing among flows on one bottleneck; an RTO resets to slow start.
  • Loss is a late, ambiguous signal: loss-based control fills buffers (bufferbloat) and misreads wireless loss; AQM and ECN signal earlier without dropping.
  • CUBIC is loss-based and the common default; BBR models bandwidth and min RTT and keeps queues empty; they behave differently under loss and share links differently.
  • A new or idle connection starts slow; 100 kB costs ~4 RTTs from cold; reuse connections and keep critical first bytes under ~14 kB.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why is it the sender’s job, not the router’s?

Routers must forward at line rate without per-flow state, and a network of them would need a global coordination protocol to allocate capacity. Pushing the inference to endpoints — the end-to-end principle again — costs nothing in the core and scales to every flow on earth.

Why start slow when the link is obviously fast?

The sender does not know the bottleneck; the fast link may end at a 1 Mbit/s cellular hop. Doubling per RTT finds the capacity within a few round trips while bounding the damage of a wrong guess.

Why is there more than one algorithm?

Because the signals available and the networks in use changed: long fat pipes broke Reno, bufferbloat broke the assumption that loss means congestion, and user-space transports made experimentation cheap. CUBIC and BBR are two defensible answers to different assumptions, not a right one and a wrong one.

Congestion control: cwnd over time

Congestion control: cwnd over time
The sender guesses how much the network can carry, grows until something breaks, then backs off. The shape of that guess is the algorithm.
rwnd ceiling = 96 MSS (receiver's limit)0326496round trips → (1 round = 40 ms)
cwnd
2 MSS ≈ 3 kB in flight
Throughput (model)
0.6 Mbit/s = cwnd / RTT
slow start: ×2 per RTT
Two different jobs: flow control (rwnd) protects the receiver from overflow; congestion control (cwnd) protects the network from collapse. The sender's window is min(rwnd, cwnd).
Educational model. Real CUBIC grows as a cubic function around the last loss point; real BBR measures delivery rate and min RTT with explicit probing phases. The sawtooth-versus-flat contrast is the lesson, not the exact numbers.
1/48 · t=0 µsEducational model

How it fails

What the failure looks like from inside real software.

  • A 100 kB API response taking 400 ms across a continent on a fresh connection: four round trips of slow start; the server’s own timing shows 3 ms.
  • Pooled connections idle for a few seconds between bursts still slow: tcp_slow_start_after_idle resets cwnd on every burst; each burst pays slow start again.
  • Video calls and SSH lagging by a second whenever a large download runs on the same home link: bufferbloat; a loss-based bulk flow has filled the modem’s buffer, and everything else queues behind it.
  • Bulk transfers over a slightly lossy satellite or cellular path crawling under CUBIC and recovering under BBR: the loss was not congestion and one controller knew it.
  • Two data centres exchanging backups over a 200 ms path at a fraction of the link rate: default buffer limits and slow-start behaviour tuned for LANs; BDP is tens of megabytes.
  • A UDP application with no congestion control sharing a link with TCP: TCP backs off, the UDP flow does not, and TCP users see the link as "slow" while the UDP application sees nothing wrong.