
In the preceding articles, we traced a data packet all the way from your computer to GitHub's servers: MAC addresses carry it hop by hop within LANs, IP addresses provide end-to-end logical addressing, and routing tables guide hop-by-hop forwarding. The entire chain works.
But there's a massive elephant in the room we've been sidestepping: the network layer's IP protocol only promises "best-effort" delivery — it guarantees nothing.
Packets may be silently dropped when a router queue overflows. They may arrive out of order after taking different paths. The sender might blast data so fast it drowns the receiver. What if your uploaded file misses a TCP segment? What if your fetch JSON response loses the second half? What if your video stream glitches out?
This is the responsibility of the transport layer. And TCP is its core protocol.
tcpdump and ss to observe real TCP connectionsUnderstand IP addresses, subnet masks, and basic routing
Have an intuitive understanding of "data packets" and "hop-by-hop forwarding" (refer to the articles above)
Imagine you're pushing a commit to GitHub:
git push origin mainGit sends your data to the remote repository over HTTP/2 or SSH. You see the progress bar fill to 100%, and the push succeeds. But have you ever wondered:
Your commit data is sliced into many TCP segments, each stuffed into an IP packet, each IP packet wrapped in an Ethernet frame, traversing dozens of routers. A switch cache may overflow and drop a frame at any hop. A fiber link may introduce bit errors. A router's queue might delay a packet due to congestion.
Why does your progress bar show 100% rather than 87%? How do you know the Git server actually received every byte?
The answer lies in TCP's three guarantee mechanisms — acknowledgments and retransmission, sliding window flow control, and congestion control. These three operate independently yet in precise coordination.
The IP protocol provides a "connectionless, unreliable, best-effort" packet delivery service. In concrete terms, three risks:
Packet loss: A router's queue fills up; the new packet is silently discarded. IP never notifies you; the sender never knows the packet was lost.
Reordering: Packets may take different paths — packet A goes via a direct fiber link (20ms), packet B detours through a congested path (50ms). They arrive in a different order than they were sent.
Flow overload: The sender might pump data at 1 Gbps, but the receiver's application processing speed is only 10 Mbps, or a bottleneck link somewhere in between runs at 100 Mbps. Without flow control, data piles up at the bottleneck and gets discarded.
TCP has a dedicated mechanism for each problem:
| Problem | TCP Mechanism |
|---|---|
| Packet loss | Sequence numbers + ACKs + timeout retransmission |
| Reordering | Sequence number ordering + cumulative ACK |
| Flow overload | Sliding window (receiver-driven) + Congestion control (network-driven) |
All three mechanisms rest on the same foundation: TCP is connection-oriented. Before transmitting any application data, both sides must establish a logical connection through a handshake, initializing their respective sequence numbers and buffers. Let's start with how that connection is "established."
You might wonder: why can't we just send data directly? IP can send packets directly, can't it?
Because TCP must do three things before any data is sent:
These three tasks are accomplished in one shot through the three-way handshake:
Client (192.168.1.10:54321) Server (151.101.2.133:443)
① SYN: ──────────────→
Seq=1000(A), no ACK
"I want to connect. My starting sequence number is 1000"
←──────────────
② SYN+ACK:
Seq=5000(B), ACK=1001
"Got it. My starting SN is 5000.
Next byte I expect from you is 1001"
③ ACK: ──────────────→
Seq=1001, ACK=5001
"Acknowledged. We're ready."What each step does:
① SYN: The client sends a segment with the SYN flag set, embedding its Initial Sequence Number (ISN). This number is not fixed — Linux uses clock-based randomization to prevent connection hijacking.
② SYN+ACK: The server acknowledges the SYN (ACK = client's ISN + 1) and simultaneously sends its own SYN carrying the server's ISN.
③ ACK: The client acknowledges the server's SYN (ACK = server's ISN + 1). After this, both sides know each other's sequence numbers, and the connection is established.
A classic question: why is the handshake three steps, not two or four?
Two is insufficient: With only SYN → SYN+ACK, the server would consider the connection established after sending SYN+ACK and start allocating resources. But the client might never receive that response (if the SYN+ACK is lost on the network), leaving the server waiting indefinitely. This is the infamous SYN Flood attack — an attacker sends massive SYNs without ever replying with the final ACK, exhausting the server's half-open connection queue.
Three is exactly right: The minimal goal of the handshake is "both sides confirm each other's sequence numbers, and both know the other side confirmed theirs." SYN + SYN+ACK + ACK satisfies this perfectly — the client confirms via step ② that the server received its ISN; the server confirms via step ③ that the client received its ISN. One more would be redundant; one fewer, insufficient.
Once the connection is established, TCP's core transmission logic revolves around two numbers: the sequence number and the acknowledgment number.
TCP doesn't operate in terms of "packets" — it works with a byte stream. The sender assigns an incrementing sequence number to each byte it transmits. If a TCP segment carries 500 bytes of data and starts at sequence number 1001:
Segment 1: Seq=1001, Data[500 bytes] → covers bytes 1001~1500
Segment 2: Seq=1501, Data[500 bytes] → covers bytes 1501~2000
Segment 3: Seq=2001, Data[300 bytes] → covers bytes 2001~2300When the receiver gets data, it replies with an ACK segment whose acknowledgment number holds "the next byte number expected." This means "I've received every byte before this number":
Client sends: Seq=1001, "Hello" (5 bytes)
Server replies: ACK=1006 → "Bytes 1001~1005 received. Next, please start from 1006"If segment 2 is lost but segment 3 arrives:
Segment 1 received: Seq=1001 (500 bytes) → Reply ACK=1501
Segment 2 lost
Segment 3 received: Seq=2001 (300 bytes) → Reply ACK=1501 (not 2301!)
"I'm still waiting for 1501"The receiver won't acknowledge segment 3 because it isn't contiguous. The sender notices segment 2's ACK never arrives, times out, and retransmits segment 2. Once segment 2 arrives, the receiver can acknowledge all the way to 2301 (segments 2 + 3 together). This is cumulative ACK — a single ACK can acknowledge all preceding bytes.
This design solves both loss and reordering in one stroke: lost segments are retransmitted; out-of-order segments wait until everything before them arrives.
Sequence numbers and ACKs tell us "was anything lost?" — but they don't tell us "can I send more?" If the sender fires off data at its own pace and the receiver can't keep up, even without packet loss, the receiver's buffer (kernel socket buffer) will be overwhelmed.
TCP's solution is the sliding window — in every ACK, the receiver tells the sender: "here's how much space I have left in my buffer."
The TCP header has a 16-bit Window Size field. Its meaning: "beyond the bytes I've already acknowledged, how many more bytes may you send?"
Sender Receiver
│ │
│ ──── Seq=1001, 500 bytes ──→ │ Receiver buffer: 500/65535
│ ←── ACK=1501, Win=65035 ──── │ "Received. You may send 65035 more"
│ │
│ ──── Seq=1501, 500 bytes ──→ │ Receiver buffer: 1000/65535
│ ←── ACK=2001, Win=55000 ──── │ "App read 10000, you may send 55000"
│ │If the receiver's application is processing slowly (e.g. decompressing data), the buffer gradually fills:
ACK=xxxxx, Win=0 ← "Stop! I'm full. Don't send."When the sender receives Win=0, it stops and periodically sends window probes: a 1-byte segment to check if the window has opened. It's like politely knocking on a door asking "can I speak now?" — rather than pounding on the door or staying silent.
Imagine two printers — a high-speed print press (sender) and a printer that only holds 10 sheets of paper (receiver). You feed paper into the latter:
The sliding window solves "how much can the receiver handle?" — but a trickier question remains: how much can the network itself handle?
Suppose the link between you and GitHub has extremely low bandwidth. You follow the receiver's large window announcement (say 65535 bytes) and blast a huge batch of data — it piles up in an intermediate router's queue, times out, and is entirely discarded. This wastes bandwidth and affects other traffic sharing the same link.
TCP's answer is a completely independent algorithm — congestion control — that runs in parallel with the sliding window. The effective sending rate is the minimum of the two:
Effective send window = min(receiver's advertised window, congestion window)Congestion control's core idea: the sender maintains its own "congestion window" (cwnd) and infers the network's capacity by detecting packet loss. Its standard algorithm has four phases:
The name is misleading — it's actually not slow; it grows exponentially:
RTT 1: cwnd=1 → send 1 segment → receive 1 ACK → cwnd=2
RTT 2: cwnd=2 → send 2 segments → receive 2 ACKs → cwnd=4
RTT 3: cwnd=4 → send 4 segments → receive 4 ACKs → cwnd=8
RTT 4: cwnd=8 → send 8 segments → receive 8 ACKs → cwnd=16In an extremely short time (often under half a second), the send rate goes from "cautious sniffing" to full throttle. This is why web page loading feels slow at first then suddenly fast.
Slow start can't grow forever. When cwnd reaches a threshold (ssthresh, the slow start threshold), TCP switches to congestion avoidance mode: only 1 MSS increase per RTT (linear growth), not doubling. This is a "cautious approach toward the ceiling" phase, preventing exponential growth from crushing the network outright.
When the sender detects loss (three duplicate ACKs, meaning subsequent bytes arrived but the missing segment didn't), it doesn't wait idly for a timeout (tens to hundreds of milliseconds) — it immediately retransmits the missing segment. This is Fast Retransmit.
After retransmission, TCP infers "the network isn't completely congested, just one packet was lost," so it doesn't reset cwnd to 1 (that would be devastating). Instead, it halves cwnd and enters Fast Recovery, continuing linear growth from there.
Before loss: cwnd=32 → ssthresh=16
After loss: cwnd=16 → Fast Recovery → Congestion Avoidance (linear growth)Slow Start Congestion Avoidance
cwnd │ ╱╲
│ ╱ ╲ ╱
│ ╱ ╲ ╱
│ ╱ ╲____ ╱
│ ╱ ╲ ╱ ← loss, cwnd halved
│ ╱ ╲ ← Fast Recovery, then linear
│╱
└──────────────────────────────────→ Time
↑ ssthresh = thresholdThis is why your download speed is stable when the network is good, and recovers immediately from an occasional hiccup — slow start ramps you up quickly, congestion control throttles you down automatically, and fast recovery brings you back to pre-loss throughput.
While connection establishment uses three steps, teardown needs four — because TCP is full-duplex, with data potentially flowing in both directions simultaneously. Each direction must be independently shut down.
Client Server
① FIN: ──────────────────────────────→
Seq=3000, ACK=...
"I have no more data to send (but I can still receive)"
←──────────────
② ACK:
ACK=3001
"Understood. I acknowledge your close"
←──────────────
③ FIN:
Seq=8000, ACK=...
"I also have no more data to send"
② ACK: ──────────────────────────────→
ACK=8001
"Acknowledged. Your direction is now closed too"①② closes the "client → server" direction; ②③ closes the "server → client" direction. Two directions × two steps each = four total.
After sending the final ACK (step ④), the client doesn't close immediately — it enters a TIME_WAIT state for 2MSL (Maximum Segment Lifetime, typically 2 minutes).
Two reasons:
① Ensure the last ACK reaches the server. If the final ACK is lost on the network, the server retransmits its FIN. The client in TIME_WAIT can re-ACK, preventing the server from retrying indefinitely.
② Let all "old connection" lingering segments naturally expire. If the client closed immediately and reopened a connection with the same 4-tuple (src IP, src port, dst IP, dst port), a delayed segment from the old connection could be mistaken as belonging to the new one. Waiting 2MSL guarantees every old segment has expired.
This is why seeing many TIME_WAIT connections on a server is normal, and why scenarios with frequent short-lived connections (like HTTP/1.0) use connection pooling to avoid port exhaustion.
Observe a three-way handshake with tcpdump
Open two terminals:
# Terminal 1: capture TCP SYN/ACK/FIN packets
sudo tcpdump -i en0 'tcp[tcpflags] & (tcp-syn|tcp-fin) != 0' -n
# Terminal 2: make an HTTP request
curl https://www.coolboiledwater.cn/ -o /dev/nullYou should see the full SYN → SYN+ACK → ACK sequence, followed by the FIN handshake.
Check your system's current TCP connection states
# macOS
netstat -an -ptcp | head -20
# Linux
ss -tan state establishedLook at the state column — can you recognize ESTABLISHED, TIME_WAIT, LISTEN? How many ESTABLISHED connections does your browser currently have?
Simulate the impact of packet loss on TCP
On Linux, use tc to introduce artificial packet loss (requires root):
# Introduce 10% packet loss on interface eth0
sudo tc qdisc add dev eth0 root netem loss 10%Then download a moderately sized file with curl and observe speed changes. Use Wireshark simultaneously to watch for retransmitted TCP segments. When done, clean up:
sudo tc qdisc del dev eth0 rootWatch the congestion window change in real time
Use the ss command (Linux) to observe a long-lived connection's congestion window:
watch -n 0.5 'ss -t -i dst 151.101.2.133'Observe the cwnd field growing exponentially during slow start, then linearly once in congestion avoidance.
You now understand how TCP builds reliable byte-stream transport on top of an unreliable IP layer. But you may have noticed something curious: not all data on the internet travels over TCP. DNS queries, video calls, online games — they often use a different set of rules: no handshake, no ACKs, no connection at all.
That set of rules is UDP — TCP's sibling at the transport layer, with a diametrically opposed design philosophy. The next article explains why "unreliable" is sometimes the better choice.