What you'll learn
Quick Answer
TCP opens a connection with a three-message exchange — SYN, SYN-ACK, ACK — so both sides confirm they can send and receive, using agreed starting sequence numbers. Once data is flowing, TCP does not blast packets at full speed: it starts with a small congestion window, grows it exponentially during 'slow start,' switches to careful linear growth once it nears the network's capacity, and reacts very differently to a timeout than it does to a few duplicate ACKs.
Why a Handshake Is Needed at All
TCP's entire reliability model is built on sequence numbers: every byte sent gets a number, and the receiver acknowledges data by naming the sequence number it expects next. For that scheme to work at all, both sides need to agree, before any real data flows, on what number each side is starting from — its Initial Sequence Number (ISN).
RFC 9293, the current TCP specification, is explicit that ISNs are not fixed or predictable from a global clock — each side effectively picks its own, partly so that old, delayed duplicate segments from a previous connection between the same two ports can't be mistaken for part of a new one. Because a receiver has no way to know on its own whether a given SYN represents a fresh, legitimate connection attempt, the receiver has to ask the sender to confirm — which is the entire reason a single one-way 'hello' message can't establish a TCP connection by itself.
Both sides need to do two things: announce their own ISN, and confirm they received the other side's ISN. That's a minimum of two pieces of information flowing in each direction — four jobs total — but because two of those jobs can be combined into a single message, it collapses to three messages, not four.
The Three-Way Handshake, Step by Step
RFC 9293 describes the abstract exchange as: one side sends its sequence number, the other side acknowledges it and sends its own sequence number back, and the first side acknowledges that. Put in terms of actual TCP flags and real numbers:
Client Server
| ---- SYN, seq=5000 ---------------> | (client proposes ISN 5000)
| <--- SYN-ACK, seq=9000, ack=5001 ---- | (server proposes ISN 9000,
| | and confirms client's 5000)
| ---- ACK, ack=9001 ---------------> | (client confirms server's 9000)The middle message does two jobs at once: it is simultaneously the server's own SYN (announcing its ISN, 9000) and its ACK of the client's SYN (confirming receipt of 5000, by acknowledging 5001 — one past the client's ISN, since SYN itself consumes one sequence number). That combination of two jobs into one segment is exactly why this is called a 'three-way' handshake and not a four-message exchange.
After the third message lands, both sides have sent their own ISN, received confirmation that ISN arrived intact, and received the other side's ISN. From this point, every byte of actual application data is numbered relative to these two starting points, and the connection is considered established.
Slow Start: The Exponential Ramp-Up
Once a connection is open, TCP does not assume it's safe to send at whatever rate the sender's network interface is capable of. Instead it tracks a congestion window (cwnd) — a cap on how much unacknowledged data can be in flight at once — and starts that cap deliberately small, because it has no information yet about how much the path between sender and receiver can actually handle without dropping packets.
For a long time, the recommended starting size (the 'initial window,' IW) was just 2 to 4 full-sized segments, per the older RFC 3390. RFC 6928 raised that ceiling to up to 10 segments — formally, IW is capped at min(10×MSS, max(2×MSS, 14,600 bytes)) — after large-scale experiments showed the more generous starting point meaningfully sped up short web transfers without triggering congestion collapse. Linux adopted this default years ago, and it's effectively the norm across the modern internet today.
From that starting point, slow start increases cwnd by one MSS (maximum segment size) for every ACK received. Because every segment currently in flight generates its own ACK, and each of those ACKs adds another MSS to the window, the practical effect is that cwnd roughly doubles every round-trip time — genuinely exponential growth, which is what lets a connection ramp up from a cautious 10-segment start to a large, near-capacity window within just a handful of RTTs, rather than crawling up one segment at a time.
This exponential growth continues until one of two things happens: cwnd reaches ssthresh (the slow-start threshold, an estimate of where the path's capacity probably is), or a packet loss is detected — whichever comes first.
Congestion Avoidance: The Careful Linear Climb
Once cwnd reaches ssthresh, RFC 5681 has TCP switch from exponential to additive growth: roughly one MSS added per round-trip time, instead of doubling. This is the 'additive increase' half of AIMD (additive increase, multiplicative decrease) — the principle that defines this whole approach to congestion control.
The reasoning is direct: near the estimated edge of the path's capacity, continuing to double the window risks a sudden burst well past what the network can absorb, causing a wave of drops all at once. Growing by a single segment per RTT instead means the sender is constantly probing gently for a little more headroom, and if the network really is at capacity, only a small amount of data is at risk when loss eventually happens — rather than however much a doubled window would have dumped in at once.
This linear climb continues, in principle indefinitely, until a loss is detected — at which point how TCP reacts depends entirely on how the loss was noticed.
Two Very Different Reactions to Packet Loss
RFC 5681 treats two ways of detecting loss very differently, because they carry very different information about how bad things actually are.
A retransmission timeout (RTO) firing, with no ACK at all, is treated as a strong signal that something is seriously wrong with the path — a long enough silence that TCP can't rule out major congestion or a broken route. The response is severe: ssthresh is set to max(FlightSize/2, 2×SMSS) — roughly half of whatever was actually in flight when the loss was noticed — and cwnd collapses all the way down to a single full-sized segment (the 'loss window'). Slow start then restarts completely from scratch, climbing back up toward that new, lower ssthresh.
Three duplicate ACKs arriving in a row tell a very different story: most of the data after the lost segment is clearly still getting through fine — that's what's generating the duplicate ACKs — so only one segment likely got dropped, not the whole path collapsing. Fast retransmit has the sender retransmit the apparently-missing segment immediately, without waiting for the RTO timer to expire at all, potentially saving a large chunk of time versus waiting for a timeout.
Fast recovery then avoids the brutal reset to one segment that a timeout would trigger: ssthresh is still halved the same way, but cwnd is temporarily inflated above that (RFC 5681 specifies roughly ssthresh + 3×SMSS, accounting for the segments the receiver's duplicate ACKs prove are already sitting in its buffer) while waiting for confirmation the retransmit worked. Once a fresh ACK for new data arrives, cwnd is 'deflated' straight down to ssthresh, and ordinary congestion avoidance resumes from there — the connection never has to crawl all the way back up from a single segment the way a timeout forces it to.
The asymmetry is the whole point: a timeout means 'treat this as a real emergency, restart cautiously,' while three duplicate ACKs mean 'one packet got unlucky, everything else is fine, correct it and keep going.'
A Real Connection Timeline, With Numbers
To make the two growth phases concrete, here's a worked walk-through consistent with the mechanics above — an illustration of the rules using realistic numbers, not a captured packet trace. Assume MSS=1,460 bytes, IW=10 segments, RTT=50ms, and an initial ssthresh estimate of 64 segments:
RTT 0: cwnd = 10 segments (initial window, slow start)
RTT 1: cwnd = 20 segments (doubled -- still slow start)
RTT 2: cwnd = 40 segments
RTT 3: cwnd = 64 segments (hit ssthresh -- switch to congestion avoidance)
RTT 4: cwnd = 65 segments (+1 MSS per RTT now, not doubling)
RTT 5: cwnd = 66 segments
... (climbing by 1 per RTT)
RTT 10: cwnd = 71 segments -- 3 duplicate ACKs arrive, one segment lost
fast retransmit: missing segment resent immediately
new ssthresh = max(71/2, 2) = 35
fast recovery: cwnd inflated to ~38 while waiting, then
deflated to 35 once new data is ACKed
RTT 11: cwnd = 35 segments (congestion avoidance resumes, +1/RTT again)
RTT 12: cwnd = 36 segmentsNotice the connection never dropped to one segment. If that same loss had instead been detected by a timeout — no duplicate ACKs, just silence — cwnd would have collapsed to one segment and climbed back up through a full new slow-start phase, which for a window that had reached 71 segments would take several more round trips just to get back to where fast recovery left off in a single step.
