TCP Congestion Control Algorithms

Why congestion control exists

A TCP sender has two limits on how fast it can transmit. The first is the receiver’s advertised window (rwnd), which protects the receiver’s buffer. The second is the congestion window (cwnd), which protects the network. Without the second limit, every sender would dump packets into shared links as fast as the receiver could absorb them, queues would overflow, routers would drop packets en masse, and the whole network would collapse into a state where almost everything retransmits and almost nothing makes progress. That scenario actually happened on the early internet in 1986, and congestion control is the response.

The core idea is simple: the sender never knows the available bandwidth in advance, so it probes. It increases its sending rate until it sees a signal of congestion, then it backs off. The differences between algorithms come down to two questions: what counts as a congestion signal, and how aggressively do you increase and decrease in response.

The effective send rate is bounded by the minimum of the two windows:

send_window = min(cwnd, rwnd)
in_flight   = bytes sent but not yet acknowledged
sender may transmit while: in_flight < send_window

The classic loss-based model: Reno and NewReno

TCP Reno treats packet loss as the congestion signal. It operates in distinct phases.

Slow start

When a connection opens, the sender has no idea what the network can handle. It starts with a small cwnd (typically 10 MSS in modern stacks, historically 1-4) and doubles it every round trip. Despite the name, this is exponential growth: each ACK increases cwnd by one MSS, so after a full window of ACKs the window has doubled.

on each ACK during slow start:
    cwnd += MSS

Slow start continues until cwnd reaches the slow start threshold (ssthresh) or a loss occurs.

Congestion avoidance

Once cwnd >= ssthresh, the sender switches to additive increase: it grows cwnd by roughly one MSS per round trip rather than per ACK.

on each ACK during congestion avoidance:
    cwnd += MSS * MSS / cwnd

This is the “AI” half of AIMD (Additive Increase, Multiplicative Decrease). The window probes upward in a gentle straight line.

Reacting to loss

Reno distinguishes two loss signals:

SignalInterpretationReaction
Three duplicate ACKsMild congestion, packet lost but later packets arrivingHalve cwnd, fast retransmit, fast recovery
Retransmission timeout (RTO)Severe congestion, pipe likely emptycwnd back to 1, restart slow start

On three duplicate ACKs, Reno sets ssthresh = cwnd / 2, sets cwnd = ssthresh, and retransmits the missing segment without waiting for a timeout. NewReno improves the recovery logic so that multiple losses within one window do not each trigger a separate, expensive timeout.


stateDiagram-v2
    [*] --> SlowStart
    SlowStart --> CongestionAvoidance: "cwnd >= ssthresh"
    SlowStart --> SlowStart: "ACK doubles window"
    CongestionAvoidance --> CongestionAvoidance: "ACK adds 1 MSS/RTT"
    CongestionAvoidance --> FastRecovery: "3 dup ACKs"
    FastRecovery --> CongestionAvoidance: "new ACK"
    CongestionAvoidance --> SlowStart: "RTO timeout"
    SlowStart --> SlowStart: "RTO resets cwnd to 1"

The classic sawtooth pattern of cwnd over time comes directly from AIMD: a slow ramp up, a sudden halving on loss, and another slow ramp.

The problem with loss-based control

Reno and NewReno equate loss with congestion. That assumption breaks in two important ways for modern networks.

First, bufferbloat. Routers and middleboxes ship with deep buffers. A loss-based sender keeps increasing its window until a buffer somewhere overflows. Before the overflow, those buffers are full, which means every packet sits in a long queue. The connection achieves high throughput but terrible latency, because the algorithm is literally optimizing toward keeping queues full.

Second, random loss. On wireless and long-distance links, packets can be lost for reasons unrelated to congestion (radio interference, bit errors). A loss-based sender misinterprets this as congestion and needlessly halves its window, leaving bandwidth unused. This is why Reno performs poorly on high-bandwidth, high-latency paths (high bandwidth-delay product).

CUBIC: scaling to fat pipes

CUBIC is the default congestion control on Linux and therefore on most servers you interact with. It is still loss-based, but it replaces the linear additive increase with a cubic function of time since the last congestion event.

W(t) = C * (t - K)^3 + W_max

W_max = window size at last loss
K     = cube root of (W_max * beta / C)
C     = scaling constant (default 0.4)
beta  = multiplicative decrease factor (0.7)

The shape matters. Right after a loss, the cubic function grows fast (the concave region), quickly reclaiming most of the bandwidth that was just lost. As the window approaches W_max, growth flattens out, letting the connection probe carefully around the level that previously caused loss. Then, past W_max, the convex region accelerates again to discover newly available bandwidth.

Critically, CUBIC’s growth depends on real elapsed time, not on RTT. This makes it RTT-fair: two flows with very different round-trip times grow their windows on the same wall-clock schedule, so the short-RTT flow does not starve the long-RTT flow the way Reno does.

BBR: modeling the pipe instead of reacting to loss

BBR (Bottleneck Bandwidth and Round-trip propagation time), developed at Google, takes a fundamentally different stance. Instead of treating loss as the signal, it builds an explicit model of the path and paces packets to match it.

BBR continuously estimates two quantities:

  • BtlBw: the bottleneck bandwidth, measured as the maximum delivery rate observed.
  • RTprop: the round-trip propagation delay, measured as the minimum RTT observed (minimum because the minimum has no queueing delay added).

The ideal operating point, the bandwidth-delay product, is BtlBw * RTprop. That is exactly enough data in flight to keep the bottleneck link busy with no standing queue. BBR aims to put precisely this much data in flight, which keeps latency low while saturating throughput.


sequenceDiagram
    participant S as "BBR Sender"
    participant N as "Bottleneck Link"
    participant R as "Receiver"
    S->>N: "Send at pacing rate"
    N->>R: "Forward (BtlBw limited)"
    R->>S: "ACK with delivery info"
    S->>S: "Update BtlBw = max rate"
    S->>S: "Update RTprop = min RTT"
    S->>S: "inflight target = BtlBw * RTprop"

BBR cycles through states: it periodically probes for more bandwidth by briefly sending faster (ProbeBW), and periodically drains the queue to re-measure true RTprop (ProbeRTT). Because it does not need loss to back off, BBR maintains high throughput on lossy links and avoids filling deep buffers, directly attacking the two weaknesses of loss-based control.

The trade-off is fairness. Early BBR could be aggressive when sharing a link with CUBIC flows, sometimes claiming more than its fair share. BBRv2 and later versions add a response to loss and to ECN signals to coexist more politely.

Comparing the families

AlgorithmSignalIncreaseDecreaseStrengthWeakness
Reno/NewRenoLossLinear (AIMD)HalveSimple, well understoodPoor on high BDP, fills buffers
CUBICLossCubic in timex0.7RTT-fair, scales to fat pipesStill loss-based, bufferbloat
VegasDelay (RTT increase)LinearLinearLow queueing, proactiveLoses to loss-based flows
BBRBandwidth + RTT modelModel-based pacingModel-basedHigh throughput, low latencyFairness vs CUBIC, complexity

ECN: an explicit alternative to inference

Every loss-based scheme is fundamentally inferring congestion from a side effect. Explicit Congestion Notification (ECN) lets routers mark packets (rather than drop them) when their queues start to build. The receiver echoes the mark back to the sender, which reacts as if a loss occurred but without losing data. Data-center variants like DCTCP use the proportion of ECN-marked packets to scale the window reduction precisely, which produces far smoother behavior than the blunt instrument of halving on loss.

What this means for backend engineers

You rarely write congestion control, but the choice affects your services directly. On Linux, you can inspect and change it:

sysctl net.ipv4.tcp_congestion_control   # current default, usually cubic
sysctl net.ipv4.tcp_available_congestion_control
sysctl -w net.ipv4.tcp_congestion_control=bbr

For latency-sensitive services behind load balancers, or for serving large objects over long-haul links, switching to BBR often improves tail latency and throughput simultaneously, because it stops filling intermediate buffers. For internal data-center traffic, ECN-based schemes like DCTCP keep queues tiny and predictable. The unifying lesson is that congestion control is a feedback loop, and the quality of the feedback signal (loss, delay, explicit marks, or a measured model) determines how well that loop performs.

comments powered by Disqus