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:
$ 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:195480cwnd: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.
cwnd grows exponentially during slow start (1x, 2x, 4x...)
____
_______/ : congestion avoidance (additive)
________/ :
____/ :
/ :
/__________________________:___
timeAIMD: 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
cwndtossthresh, 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
cwndto 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:
- A sender pushes data slightly faster than the bottleneck link drains.
- The router's queue fills; packets wait in the buffer instead of being dropped.
- RTT climbs as queued packets wait — but no packet is lost, so the sender sees no congestion signal and keeps the send rate high.
- 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
$ 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
cwndis 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.