The Runtime Theory
Networking

TCP Congestion Control Explained: Slow Start, cwnd, and Bufferbloat

How TCP congestion control actually works — slow start, AIMD, cwnd, Reno vs CUBIC vs BBR, fast retransmit, and the bufferbloat problem you can observe with ss.

The Runtime Theory Team3 min read#tcp#congestion-control#bufferbloat#cwnd#networking
On this page

TCP has no idea what the network looks like. It cannot see the routers between you and the server, their queue depths, or their bandwidth. Every decision about how fast to send is an inference from two signals: the acknowledgements (ACKs) that come back, and the ones that don't. Congestion control is the machinery that turns those signals into a send rate — and it's the difference between a network that works and a network that collapses.

The one variable that matters: cwnd

The sender keeps a single number, cwnd (congestion window), which caps how many bytes can be in flight — sent but unacknowledged. The kernel tracks it per connection, and you can read it live:

bash
$ ss -tin
State Recv-Q Send-Q  Local Address:Port  Peer Address:Port  Process
ESTAB  0      0      10.0.0.5:443        192.168.1.20:44122
    cubic rto:203 rtt:12.5/7.5 ato:40 mss:1448 cwnd:22
    ssthresh:21 bytes_acked:195480

cwnd:22 means the sender may have at most 22 × MSS (~32 KB here) unacknowledged at once. The entire science of congestion control is: how big should cwnd be, and how fast should it grow?

Slow start: the probe

At connection start the sender knows nothing, so it starts at a tiny cwnd — 10 segments on modern Linux (RFC 6928) — and doubles it for every round trip in which all segments are ACKed. Each RTT, the rate doubles: 10, 20, 40, 80 segments. That exponential ramp is the "slow start" — slow in rate of change, not slow in absolute terms; after 10 RTTs you're at thousands of segments.

The ramp cannot continue forever. The sender tracks ssthresh (slow-start threshold) and switches to additive growth when cwnd crosses it — or when the first loss arrives.

text
cwnd grows exponentially during slow start (1x, 2x, 4x...)
                          ____
                  _______/    :  congestion avoidance (additive)
         ________/           :
    ____/                    :
   /                         :
  /__________________________:___
                               time

AIMD: the other half of the algorithm

Once out of slow start, the sender enters congestion avoidance: cwnd grows by ~1 segment per RTT — additive increase. When loss happens, cwnd is cut — multiplicative decrease. This is the AIMD core shared by every classic TCP:

  • On 3 duplicate ACKs (three packets after a lost one arrived out of order): the sender treats this as "light congestion", halves cwnd to ssthresh, and fast retransmits the missing segment immediately — no waiting for a timeout.
  • On a timeout (no ACK for RTO seconds): the sender assumes the path is congested or dead, drops cwnd to 1 segment (or the initial window), and restarts slow start.

Where bufferbloat comes from

Every router along the path buffers packets before dropping them. Those buffers exist to absorb bursts — but they also hide congestion. Here is the failure mode:

  1. A sender pushes data slightly faster than the bottleneck link drains.
  2. The router's queue fills; packets wait in the buffer instead of being dropped.
  3. RTT climbs as queued packets wait — but no packet is lost, so the sender sees no congestion signal and keeps the send rate high.
  4. Queues stay full. You now have dial-up latency on a fiber link: throughput is unchanged, latency has gone from 20 ms to 400 ms.

Classic TCP only ever sees loss or RTT, so a deep buffer means it never gets the "slow down" signal. That's why BBR (Google's congestion control, TCP_BBR on Linux) is different: it measures the bottleneck rate and minimal RTT continuously and sends at a rate it has proven the path can carry, ignoring loss until the queue is provably empty. BBR doesn't avoid loss, it avoids filling the buffer — which is exactly what makes video conferencing and gaming feel snappier under load.

What you can actually observe

bash
$ tcpdump -ni any tcp port 443 -c 40   # watch slow start in the wild
$ ss -tin | grep -E "cwnd|cubic|bbr"   # which algorithm, what window
$ net.ipv4.tcp_congestion_control = bbr   # /etc/sysctl.conf (kernel 4.9+)

Run tcpdump while downloading a large file and you'll see the ACK rate double each RTT at the start. That doubling is not the network getting faster — it's the sender probing the path's capacity, one doubling at a time.

The mental model

  • cwnd is a rate limit derived from feedback, not a parameter you set.
  • Slow start finds the ceiling; AIMD lives just below it; loss is the only signal.
  • Bufferbloat exists because routers absorb congestion instead of reporting it.
  • If your app sends small, latency-sensitive traffic, tune your writer for it — but if latency collapses under load, the problem is usually the queue in the network, not your code.

Know your cwnd, know your RTT, and you can predict exactly when the first drop lands — before the network tells you.