Networking Fundamentals

Ch.22: TCP Congestion Control, Slow Start, and Congestion Avoidance

By Ayush Arora17 min read

Inspired by: YouTube

When we dissected the TCP segment header, we ran into the CWR and ECE flags and deferred a proper explanation to a dedicated post. This is that post. It's also the natural follow-up to TCP Flow Control: flow control and congestion control are frequently confused for the same mechanism, but they protect two entirely different things.


Flow control vs. congestion control

Flow control makes sure a sender never overwhelms the receiver. It's governed by the receiver's advertised Window Size, which reflects how much free space is left in the receiver's receive buffer.

Congestion control makes sure a sender never overwhelms the network sitting between sender and receiver, the routers and links a segment has to hop across to get there.

The sender and receiver are usually different machines, possibly a laptop talking to a server on AWS, connected only by the internet in between: a chain of routers, each with its own limited memory. Flow control has no visibility into any of that. It only knows about the receive buffer at the far end. Congestion control exists specifically to protect that in-between path, the routers, from being overloaded, independently of whether the receiver itself has plenty of room.


Why the receiver being fine isn't enough

For the examples in this post, assume the same simplifications used in the flow control post: one segment carries exactly 1 byte (1 MSS = 1 byte, instead of the usual 1460 bytes covered in MTU, MSS, and Path MTU Discovery), the round-trip time (RTT) is 1 second, and for simplicity, every segment gets its own individual ACK rather than a cumulative one.

Say the receiver has advertised a Window Size of 4 bytes, meaning it can accept 4 segments at once. Based purely on flow control, the sender ships all 4 segments in one shot:

The receiver never even sees segments 3 and 4. Nothing is wrong with the receiver, it has plenty of room, but a router in the middle only had buffer space for 2 segments at that instant and silently dropped the rest. This is exactly the gap flow control can't see and congestion control exists to close: the sender's window can't depend on the receiver's capacity alone, it also has to account for how much the network in between can carry.


The congestion window

To account for the network's capacity, TCP maintains a second value alongside the receiver's advertised window: the congestion window (cwnd). It represents, as best TCP can estimate, how much data the network path can currently carry without overloading a router along the way.

The sender's actual window is never based on the receiver's window alone:

Sender's Window = min(Receiver's Window, Congestion Window)

In the example above, the receiver's window was 4 bytes but the network could only handle 2. Sending min(4, 2) = 2 bytes at a time would have avoided the drop entirely.

The receiver's window is easy to know: the receiver states it explicitly, during the 3-way handshake and in every subsequent ACK. The congestion window is not so simple. Nobody tells the sender how much a router's buffer can hold, and with potentially hundreds of routers between sender and receiver, there's no one to ask. TCP has to estimate the congestion window through trial and error: send some data, watch what happens, and adjust. That estimation is exactly what Slow Start and Congestion Avoidance do.


TCP Slow Start

Slow Start is the first of the two algorithms TCP uses to grow the congestion window. Despite the name, it isn't slow for long: it starts from a small, conservative value and then grows the congestion window exponentially, roughly doubling it every round trip, for as long as segments keep getting acknowledged successfully.

The rule: on every ACK received, cwnd += 1 MSS.

Walking through it with the same 1-byte-segment, 1-second-RTT assumptions:

Round tripcwnd at startACKs receivedcwnd after (+1 MSS per ACK)
1111 + 1 = 2
2222 + 2 = 4
3444 + 4 = 8
4888 + 8 = 16
5161616 + 16 = 32
6323232 + 32 = 64
7646464 + 64 = 128

TCP starts by assuming cwnd = 1 MSS and sends a single segment. Its ACK arrives, so cwnd is incremented by 1, becoming 2. The sender now sends 2 segments; both get acknowledged, so cwnd gets +1 for each ACK, becoming 4. Send 4, get 4 ACKs, cwnd becomes 8. The pattern keeps doubling every round trip, exactly because each of the N segments sent in a round trip earns its own +1 MSS on cwnd, adding up to +N, which doubles the previous value.

This exponential climb can't continue forever, or the network would eventually get flooded. TCP needs to know where to back off, and that's the job of the slow start threshold.


Slow start threshold (ssthresh)

ssthresh (also written SS Thresh) is a variable, measured in the same units as cwnd (bytes, or MSS-equivalent), that tells TCP when to stop being aggressive. Once cwnd reaches ssthresh, TCP switches algorithms from Slow Start to Congestion Avoidance.

Continuing the example with ssthresh = 128 bytes: cwnd climbs 1 → 2 → 4 → 8 → 16 → 32 → 64 → 128, and the moment it hits 128, TCP stops using Slow Start's exponential growth and switches to Congestion Avoidance's much gentler growth instead.


TCP Congestion Avoidance

Where Slow Start doubles cwnd every round trip, Congestion Avoidance grows it linearly, by a much smaller amount per ACK:

On every ACK received: cwnd += 1 / cwnd

With cwnd starting at 128 (having just switched over from Slow Start), and 128 total segments in flight, each getting its own ACK:

ACK #cwnd beforecwnd += 1/cwndcwnd after
1st128+ 1/128128.0078
2nd128.0078+ 1/128128.015
32nd~128.23+ 1/128128.25
64th~128.48+ 1/128128.5
128th~128.99+ 1/128129

Note: TCP can only ever send whole bytes/segments, never a fractional one, so cwnd values like 128.015 aren't literally usable as-is. In real implementations, cwnd is tracked internally as a byte count plus an accumulating fraction; the sender only gains one additional MSS of sending capacity once that internal fraction has accumulated enough to round up to a full segment. The fractional values above are the conceptual math behind that gradual growth, not bytes TCP actually puts on the wire.

It takes all 128 ACKs, a full round trip's worth of acknowledgments, for cwnd to climb by just 1 MSS, from 128 to 129. Compare that to what Slow Start would have done with the same 128 ACKs in one round trip: cwnd + 128 = 256, almost double. That contrast is the entire point of Congestion Avoidance: it deliberately trades speed for caution once the network is already carrying a substantial load, growing the window "just enough" to probe for more capacity without aggressively flooding the path.

TCP uses both algorithms, just at different times: Slow Start to ramp up quickly from a cold start, then Congestion Avoidance to creep upward more carefully once there's real traffic to be careful about. Plotting cwnd against round trips makes the handoff obvious: a sharp exponential curve up to ssthresh, followed by a near-flat linear climb after it.

Line chart of cwnd against round trip number, showing exponential growth from 1 to 128 during Slow Start, then a flat, near-linear crawl from 128 to 129 during Congestion Avoidance, with a dotted reference line marking ssthresh at 128

Everything up to RTT 7, where the blue cwnd curve meets the dotted ssthresh line, is Slow Start's exponential doubling; the crawl from 128 to 129 that takes an entire additional round trip is Congestion Avoidance already taking over.


Detecting congestion

Congestion window keeps growing, via Slow Start then Congestion Avoidance, until the sender actually experiences congestion. TCP has three ways to detect that:

1. Retransmission timeout (RTO)

Every time the sender transmits a segment, it starts a timer. If the ACK for that segment doesn't arrive before the timer expires, TCP assumes the segment was dropped somewhere along the network path due to congestion.

TCP treats a timeout as a strong indication of congestion: a serious problem, not a minor blip.

2. Triple duplicate ACKs

Say the sender transmits segments 1, 2, and 3, but segment 2 is dropped in transit while 1 and 3 arrive fine. The receiver has segment 1, is missing segment 2, and now has segment 3 sitting out of order. Every time it gets another out-of-order segment, it re-sends the same ACK, requesting segment 2 again.

Three duplicate ACKs for the same sequence number tells the sender that one specific segment likely got lost due to congestion, but everything else is still getting through fine. TCP treats this as a milder signal than a timeout: annoying, but not catastrophic.

3. Explicit Congestion Notification (ECN)

Both of the above only fire after a packet is already lost. ECN lets a router warn about congestion before it starts dropping anything, by marking a packet instead of dropping it.

Every IP packet header carries a 2-bit ECN field (part of the same byte that used to be called Type of Service), and those 2 bits hold one of four values:

BitsNameMeaning
00Not-ECTSender doesn't support ECN; a congested router has no option but to drop
10ECT(0)Sender supports ECN: "mark me instead of dropping me if you need to"
01ECT(1)Same as ECT(0), a second codepoint reserved for experimental use
11CE (Congestion Experienced)A router along the path marked this packet because it's nearing its buffer limit

A packet doesn't start out life at 11. It starts as 00, unless the sender and receiver both support ECN, in which case the sender marks its outgoing packets ECT(0) (10) instead. That support isn't assumed, it's negotiated during the 3-way handshake, where the SYN and SYN-ACK segments carry the ECE and CWR flags with special handshake-only meanings, each side essentially declaring "I support ECN" before any data flows. Only an ECT-marked packet is eligible to be marked instead of dropped; a router facing congestion from Not-ECT traffic still has to drop it the old-fashioned way.

The router can only talk to whichever host it's forwarding toward, so it rewrites the packet heading to the receiver, not the sender. But congestion control is entirely the sender's responsibility, so the receiver needs a way to relay that warning back. That's what the ECE (ECN-Echo) flag does, this time in its data-transfer meaning rather than its handshake one: the receiver sets ECE = 1 on its next ACK back to the sender. Once the sender sees ECE = 1, it reduces its congestion window as a precaution and replies with CWR (Congestion Window Reduced) set to 1, confirming to the receiver, "acknowledged, I've already reduced my window." Since the network never actually dropped anything, no retransmission is needed at all, TCP simply reacts to the warning as if it had detected mild congestion on its own.


How cwnd and ssthresh react to each detection method

All three detection methods reduce cwnd and update ssthresh, but by different amounts, because they represent different severities of congestion.

Triple duplicate ACK / ECN: back off, don't restart

Say cwnd had grown to 800 bytes (still using the 1-byte-segment simplification, so this means 800 MSS-equivalents) when a triple duplicate ACK or an ECN warning arrives:

Because this isn't a severe problem, just one lost segment or an early warning, TCP doesn't restart from scratch. It halves cwnd, sets the new ssthresh to that same halved value, and skips Slow Start entirely, continuing directly with Congestion Avoidance from 400. For the ECN case specifically, the sender also sends CWR = 1 on its next outgoing segment to confirm the reduction back to the receiver.

Line chart of cwnd against round trip number, climbing exponentially to 128 then linearly to 800 where congestion is detected, dropping sharply to 400, then resuming a gentle climb, with a dotted reference line marking ssthresh stepping from 128 to 400 at the congestion point

The dotted ssthresh line steps from its original 128 up to the recalculated 400 the moment congestion hits. Notice the shape: cwnd only drops halfway, to 400, and immediately resumes climbing from there with Congestion Avoidance's gentle linear growth. It never touches Slow Start again.

Retransmission timeout: start over

If instead that same 800-byte congestion window experiences a timeout:

A timeout is treated as a serious problem, so TCP doesn't just back off, it restarts from the very beginning with Slow Start, climbing exponentially again from cwnd = 1. The difference from a fresh connection is that ssthresh is no longer at its original value, it's now 400, so this time Slow Start only runs until cwnd reaches 400 (instead of the original 128) before switching over to Congestion Avoidance.

Note: The new ssthresh isn't always lower than the old one, it's simply cwnd / 2 at the moment congestion is detected, so whether it ends up higher or lower than the previous ssthresh depends entirely on how large cwnd had grown by that point. In this walkthrough, the original ssthresh was deliberately set low (128 MSS) for the sake of demonstrating the Slow Start -> Congestion Avoidance switch early. Congestion didn't actually hit until cwnd had climbed to 800 MSS, well past that original threshold, so the recalculated ssthresh (400) ends up higher than the original 128, not lower.

Line chart of cwnd against round trip number, climbing exponentially to 128 then linearly to 800 as in the previous chart, but this time crashing all the way down to 1 on timeout and reclimbing the full exponential Slow Start curve up to a dotted reference line marking the new ssthresh of 400

The dotted ssthresh line steps from 128 to 400 the instant the timeout crashes cwnd back to 1. Compare the shape to the triple-duplicate-ACK/ECN case above: cwnd climbs to the same 800 before congestion is detected, but instead of a shallow dip to half, it crashes all the way down to 1 MSS and has to climb the entire exponential Slow Start curve over again, this time stopping at the new ssthresh of 400 (instead of the original 128) before handing off to Congestion Avoidance.

Detection methodSeverityNew ssthreshNew cwndAlgorithm after
Triple duplicate ACKMildcwnd / 2cwnd / 2Congestion Avoidance (continues)
ECNMild (proactive)cwnd / 2cwnd / 2Congestion Avoidance (continues)
Retransmission timeoutSeverecwnd / 21 MSSSlow Start (restarts)

The sender's window still can't exceed the receiver's

One last detail worth nailing down: since cwnd keeps growing with every successful round of ACKs, could it ever grow past the receiver's advertised window? Say the receiver's window is 1000 bytes and, absent any congestion, cwnd climbs all the way to 1001.

Sender's Window = min(Receiver's Window, Congestion Window) = min(1000, 1001) = 1000

The sender's window is always the minimum of the two, never either one alone. Letting cwnd override the receiver's window would defeat the entire purpose of flow control: the network might well be able to carry 1001 bytes, but the receiver explicitly said it can only handle 1000, and sending past that overwhelms the receiver regardless of what the network could technically deliver.


Summary