On this page
TCP
The most complex protocol on the Internet—three-way handshake for connection establishment, sliding window for flow control, CUBIC/BBR for congestion control, SACK to reduce retransmissions. TCP builds a reliable byte stream abstraction on top of unreliable IP; every mechanism is the result of decades of engineering practice.
Overview
TCP is the fundamental reliable transport protocol of the Internet, defined in RFC 793 in 1981, carrying the vast majority of Internet traffic (HTTP, SSH, SMTP, etc.). It provides ordered byte streams, packet loss retransmission, flow control, and congestion control on top of an unreliable IP layer.
There is one main thread to understanding TCP: it must protect two things simultaneously. The sliding window protects the peer (don't overflow the receiver's buffer), while congestion control protects the network in the middle (don't overflow the queues of intermediate routers). The former has explicit signals (the peer directly tells you how large the window is); the latter does not—the network does not actively report "I am almost full," so the sender can only guess. TCP's core innovation is not "guaranteeing reliability," but precisely this art of guessing: congestion control. Over the past 40 years, congestion algorithms have evolved from Tahoe/Reno to CUBIC (Linux default) and then to BBR (Google 2016), reflecting the Internet's transition from copper wires to mobile networks to data centers.
TCP Header
0 1 2 3
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data | |U|A|P|R|S|F| |
|Offset | Rsrvd |R|C|S|S|Y|I| Window |
| (4) | (3) |G|K|H|T|N|N| (16 bits) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options (if Data Offset > 5) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Semantics of several key fields:
- Sequence Number: The sequence number of the first data byte in this segment. The Initial Sequence Number (ISN) is randomized (to prevent sequence prediction attacks).
- Acknowledgment Number: The sequence number of the next byte expected to be received—this is cumulative ACK: ack=1000 means bytes up to 999 have all been received.
- Data Offset: Length of the TCP header, in units of 4 bytes, with a minimum of 5 (20-byte header without options).
- Flags: CWR, ECE (used for Explicit Congestion Notification, ECN), URG, ACK, PSH, RST, SYN, FIN.
- Window: The receiver's current remaining buffer size. The original field is only 16 bits (maximum 64KB); modern TCP extends this via the window scale option (see below).
Key Options
| Option | kind | When it appears | Purpose |
|---|---|---|---|
| MSS | 2 | SYN only | Declares the maximum segment size this side can receive, typically = MTU(1500) − IP(20) − TCP(20) = 1460 |
| Window Scale | 3 | SYN only | Shifts the window value left by N bits (maximum 14), breaking the 64KB limit |
| SACK Permitted | 4 | SYN only | Declares support for Selective Acknowledgment |
| SACK | 5 | During data transfer | Reports received non-contiguous intervals, allowing retransmissions to fill only the gaps |
| Timestamp | 8 | Every packet | RTT measurement (RTTM) + protection against sequence number wraparound (PAWS) |
Two worth expanding on:
Why Window Scale is necessary — Without it, the maximum window is 64KB. The upper limit of data the sender can "fill in the pipe" is min(window, cwnd), while the link capacity is BDP (Bandwidth × RTT). For a 1Gbps link with a 50ms RTT, BDP ≈ 6.25MB; a 64KB window can only achieve 64KB / 50ms ≈ 10Mbps—no matter how large the bandwidth, it cannot be fully utilized. With scale (e.g., window=65535, scale=7 → actual 8MB), the window can keep up with modern links.
What SACK solves — Cumulative ACK can only express "how many consecutive bytes have been received." When bytes 2001-2999 are lost, even if 3000-4000 have already been received, the ACK can only stop at 2001, and the sender might retransmit data that was already received later. SACK allows the receiver to explicitly say "I received 1000-2000 and 3000-4000," so the sender only fills the single gap of 2001-2999.
Connection Establishment: Three-Way Handshake
sequenceDiagram
participant C as Client
participant S as Server
Note over C: CLOSED → SYN_SENT
Note over S: LISTEN
C->>S: SYN, seq=x
Note over S: SYN_RCVD
S->>C: SYN+ACK, seq=y, ack=x+1
C->>S: ACK, seq=x+1, ack=y+1
Note over C,S: Both ESTABLISHED
Why three times, and why not two? The goal of the handshake is for both sides to confirm that "the bidirectional channel is available." With two steps: Client sends SYN, Server replies with SYN+ACK—at this point, the Client has confirmed bidirectional reachability, but the Server does not know if its SYN+ACK arrived. The third ACK is this confirmation for the Server. One step fewer, and one side remains uncertain about the channel state.
Why does SYN consume a sequence number? Both SYN and FIN must be "acknowledgeable"—if they did not consume sequence numbers, their loss could not be detected and retransmitted via ACK mechanisms. Upon receiving ACK(x+1) for SYN(seq=x), it is proven that "your SYN has been received." This is also why ack values in the handshake diagram are x+1 and y+1.
Incidentally: the SYN_RCVD state consumes server resources (half-open connection queue), making it a target for SYN flood attacks—Linux uses syncookies to encode state into the ISN and return it to the client, achieving stateless responses.
Connection Teardown: Four-Way Wave
sequenceDiagram
participant A as Active Closer
participant P as Passive Closer
A->>P: FIN, seq=u
Note over A: FIN_WAIT_1
P->>A: ACK, ack=u+1
Note over P: CLOSE_WAIT — Application layer hasn't called close yet
Note over A: FIN_WAIT_2
P->>A: FIN, seq=v
Note over P: LAST_ACK
A->>P: ACK, ack=v+1
Note over A: TIME_WAIT — Waits 2MSL then CLOSED
Note over P: CLOSED
The teardown is four steps rather than three because closing involves two independent half-closures: receiving a FIN only means "the other party will no longer send," but this side may still have data to send (hence the gap between ACK and its own FIN, which is CLOSE_WAIT, the length of which depends on when the application calls close).
Why wait 2MSL (approx. 120s) in TIME_WAIT:
- Ensure the final ACK arrives — If it is lost, the peer will retransmit FIN; the active closer must remain "alive" to respond again, otherwise the peer will be stuck in LAST_ACK forever.
- Allow stray packets from the old connection to expire in the network — Prevents delayed packets from the previous connection from being mistaken for belonging to a new connection that reuses the same 5-tuple.
Mitigation strategies for large accumulation of TIME_WAIT (common on the client side with high-concurrency short-lived connections):
| Strategy | Scope | Description |
|---|---|---|
tcp_tw_reuse=1 | Client | Allows reusing TIME_WAIT sockets for new connections, relying on monotonically increasing timestamps to ensure safety |
SO_REUSEADDR | Server | Allows immediate port binding after restart, without waiting for TIME_WAIT to clear |
tcp_fin_timeout | Orphan connections | Only affects the FIN_WAIT_2 timeout for orphan sockets; often mistakenly tuned as MSL |
| Connection Pool | Architecture level | The fundamental solution—reuse long-lived connections to reduce TIME_WAIT generation at the source |
Sliding Window and Flow Control
Flow control answers the question: How does the sender know how much the peer can still accept? The answer is straightforward—the receiver advertises its remaining buffer (Window field) in every ACK, and the sender ensures that the amount of "sent but unacknowledged" data does not exceed it.
Sequence space from the sender's perspective:
Acknowledged Sent but Unacknowledged Can Send Outside Window
──────────────┬──────────────────┬────────────────┬─────────────
SND.UNA SND.NXT SND.UNA+SND.WND
The sending condition is SND.NXT < SND.UNA + SND.WND.
Two classic pitfalls, both caused by "window shrinking":
- Zero Window Deadlock: The receiver's buffer is full, advertising window=0, so the sender stops sending. Later, the receiver frees space and sends a window update—if this update is lost, both sides wait for each other, causing a deadlock. Therefore, TCP has a persist timer: the sender periodically sends a 1-byte window probe, forcing the receiver to reply with an ACK containing the latest window.
- Silly Window Syndrome: The receiver advertises window=1 for every 1 byte freed, so the sender sends 1 byte at a time—endless tiny packets where header overhead far exceeds payload. Both sides have mitigations: the sender uses the Nagle algorithm (wait until MSS is filled or an ACK is received before sending), and the receiver uses Clark's scheme (do not advertise small windows until enough space has been freed).
Congestion Control: CUBIC → BBR
Flow control has explicit numbers from the peer; congestion control does not—the network will not tell you it is almost full. The sender can only infer from circumstantial evidence: packet loss? Latency increase? The choice of "what signal to use to infer congestion" is what distinguishes each generation of algorithms: CUBIC treats packet loss as the signal, while BBR treats measured bandwidth and RTT as the signal.
The sending rate is controlled by the congestion window cwnd; the actual amount that can be sent is min(cwnd, peer's window).
CUBIC (Linux Default)
CUBIC's growth curve is a cubic function, using the window size at the time of the last loss, W_max, as "memory":
cwnd = C × (t − K)³ + W_max K = ³√(W_max × β / C), β = 0.7
Intuitively: after loss, cwnd is cut to 0.7 times, then quickly approaches W_max, slows down as it nears (the plateau of the curve), and accelerates again to probe higher levels once confirmed safe—the cubic curve naturally provides this "fast-slow-fast" shape, and growth depends only on the time t since the last loss, not on RTT (much faster than Reno's linear increase per RTT on large-and-fat pipes).
flowchart LR
SS["🚀 <b>Slow Start</b><br><small>cwnd doubles every RTT</small>"] -->|"Reach ssthresh"| CA["📈 <b>Congestion Avoidance</small><br><small>Grows along CUBIC curve</small>"]
SS -.->|"Packet Loss"| MD["💥 <b>Decrease</b><br><small>cwnd ×0.7, record W_max</small>"]
CA -.->|"Packet Loss"| MD
MD ==> CA
classDef grow fill:#3fb9501f,stroke:#3fb950,stroke-width:2px
classDef steady fill:#4493f81f,stroke:#4493f8,stroke-width:2px
classDef loss fill:#f851491f,stroke:#f85149,stroke-width:2px
class SS grow
class CA steady
class MD loss
CUBIC's problem lies in its signal itself: packet loss is a too-late and not always accurate signal.
- On bufferbloat links (huge router buffers), packets are dropped only when the queue is full—by then, latency has already spiked to hundreds of milliseconds, yet CUBIC is still stepping on the gas.
- On high-bandwidth × long-RTT (large BDP) links, recovering from a 30% cut takes a long time.
- On links with random packet loss (WiFi, transoceanic), noise is misjudged as congestion, causing unnecessary speed reductions.
BBR (Bottleneck Bandwidth and RTT, Google 2016)
BBR changes the signal: instead of waiting for packet loss, it directly models the link. Treat the path as a pipe, measure two parameters—bottleneck bandwidth BW (maximum recent delivery rate) and propagation delay min RTT (minimum recent RTT). The pipe's capacity is BDP = max(BW) × min(RTT). Pin the in-flight data volume near BDP: fully utilize bandwidth without queuing in routers.
flowchart LR
S["🚀 <b>Startup</b><br><small>Doubles every round<br>Quickly fill the pipe</small>"] -->|"Delivery rate<br>no longer grows"| D["🫗 <b>Drain</b><br><small>Drain queued data<br>exceeding BDP</small>"]
D -->|"inflight drops back to BDP"| P["⚖️ <b>ProbeBW</b><br><small>Steady state: gain cycle<br>Lightly probe for new bandwidth</small>"]
P -->|"Every 10s"| R["📏 <b>ProbeRTT</b><br><small>Press to 4 packets<br>Re-measure min RTT</small>"]
R ==>|"Obtain clean min RTT"| P
classDef grow fill:#3fb9501f,stroke:#3fb950,stroke-width:2px
classDef warn fill:#d299221f,stroke:#d29922,stroke-width:2px
classDef steady fill:#4493f826,stroke:#4493f8,stroke-width:2.5px
classDef neutral fill:#64748b1f,stroke:#64748b,stroke-width:2px
class S grow
class D warn
class P steady
class R neutral
- Startup: Similar to slow start, doubles every round to quickly fill the pipe.
- Drain: After filling, inflight exceeds BDP (the excess is queued); drain it first.
- ProbeBW: Steady state. Use a gain cycle
[5/4, 3/4, 1, 1, 1, 1, 1, 1]to periodically lightly step on the gas to probe for new bandwidth, then yield. - ProbeRTT: Every 10s, press inflight to very low levels to empty the queue and measure true propagation delay—otherwise, the queue created by itself would pollute the min RTT measurement.
Actual results (Google YouTube deployment data): Average latency reduced by approx. 33%, retransmissions reduced by approx. 20%. Limitations also exist: BBR v1 is unfair to CUBIC flows (probes are too aggressive, squeezing coexisting loss-based flows); v2 (with improvements accompanying RFC 9438) adds responses to packet loss and ECN, improving fairness.
Comparison Table
| CUBIC | BBR | |
|---|---|---|
| Congestion Signal | Packet Loss | Measured BW + RTT Model |
| Bufferbloat Links | Queue fills, latency spikes | inflight ≈ BDP, low latency |
| Random Loss Links | Misjudged congestion, throughput collapses | Does not automatically reduce speed due to loss, stable throughput |
| Fairness | Good convergence among peers | v1 unfair to loss-based flows, improved in v2 |
| Deployment | Linux Default | sysctl net.ipv4.tcp_congestion_control=bbr |
This approach to congestion control is not exclusive to TCP—QUIC reimplements the entire suite in user space (see UDP and QUIC Transport Layer), allowing algorithm changes without modifying the kernel. For tuning practices, see Network Performance and Tuning.
References
- RFC: 793, 7323 (timestamp/window scale), 2018 (SACK), 7413 (TFO), 5681, 8312 (CUBIC), 9438 (BBRv2)
- Source Code:
net/ipv4/tcp_input.c(tcp_rcv_established),net/ipv4/tcp_cubic.c,net/ipv4/tcp_bbr.c - Tools:
ss -tianp,nstat -a | grep Tcp,bpftrace -e 'k:tcp_retransmit_skb { @[comm] = count(); }'
Keywords: TCP, three-way handshake, TIME_WAIT, sliding window, CUBIC, BBR, SACK, TFO, Window Scale, congestion control, BDP, bufferbloat