Build the mental model
IP delivers individual packets and promises nothing: a packet can be lost, duplicated, delayed, or arrive out of order. TCP's job is to build a reliable, ordered byte stream on top of that, and it cannot start until both ends agree on where the stream begins. That agreement is the entire point of the handshake. The client sends a SYN carrying its initial sequence number (ISN); the server replies with a SYN-ACK carrying its own ISN plus an acknowledgement of the client's; the client sends a final ACK. Three segments are needed because a connection is full-duplex: there are two independent streams, and each one needs its starting point announced and confirmed.
The ISN is neither zero nor a simple counter. It is randomized, for two reasons. An off-path attacker who can guess the next sequence number can inject data into someone else's connection without ever seeing the traffic. And a straggling segment from an earlier connection using the same four-tuple could otherwise be accepted as legitimate data in a new one. Randomizing the ISN makes both impractical.
Each side walks its own state machine. The client goes SYN_SENT, then ESTABLISHED; the server waits in LISTEN, moves to SYN_RECEIVED, then ESTABLISHED. Closing, though, is four-way rather than three-way, because each direction is shut down separately with its own FIN and ACK — which is exactly why one side can keep sending after the other has stopped. The side that closes first then sits in TIME_WAIT for twice the maximum segment lifetime, deliberately holding the port so that a late segment from the dead connection cannot be mistaken for data belonging to a new connection reusing the same four-tuple.
TCP THREE-WAY HANDSHAKE AND FOUR-WAY CLOSE
------------------------------------------
Client Server
| |
CLOSED LISTEN
|------------------ SYN seq=x ----------------->|
SYN_SENT SYN_RECEIVED
|<---------- SYN-ACK seq=y ack=x+1 ------------|
|------------ ACK seq=x+1 ack=y+1 ------------>|
ESTABLISHED ESTABLISHED
| ... application data flows ... |
|----------- FIN (I am done sending) ---------->|
FIN_WAIT_1 CLOSE_WAIT
|<-------------------- ACK ----------------------|
|<---------- FIN (now I am done too) -----------|
LAST_ACK
|--------------------- ACK --------------------->|
TIME_WAIT CLOSED
waits 2 x MSL, then CLOSEDConnect it to a real scenario
Suppose you are debugging why your mobile app's login feels slow on a 3G connection. The request itself is 400 bytes and the server responds in 20 ms, yet users wait close to a second. Walk the handshake and the arithmetic explains it. Before a single byte of your POST leaves the phone, TCP has spent a full round trip on SYN and SYN-ACK. TLS then needs one or two more on top of that. On a link with a 200 ms round-trip time, 400-600 ms are gone before the application protocol has even started. This is why connection reuse is so high-leverage: a client with a connection pool, or a server honouring Connection: keep-alive, pays the handshake once and amortises it over hundreds of requests. It is also why HTTP/2 over a single connection beats six parallel HTTP/1.1 connections on a slow link.
The other place the handshake shows up in production is TIME_WAIT. A proxy that opens and closes a connection per request accumulates thousands of sockets stuck in TIME_WAIT on whichever side closed first, each holding a local port for about two minutes. When ephemeral ports run out you start seeing 'cannot assign requested address'. The fix is almost never to shorten TIME_WAIT — it is to stop closing connections you are about to reopen.
Try the working example
import struct
# TCP flag bits, least significant bit first (bit 0 = FIN)
FLAG_NAMES = ["FIN", "SYN", "RST", "PSH", "ACK", "URG"]
def build_tcp_header(src_port, dst_port, seq, ack, flags, window):
"""Pack a 20-byte TCP header. data_offset=5 means 5 * 4 = 20 bytes."""
data_offset = 5
offset_flags = (data_offset << 12) | flags
return struct.pack(
"!HHIIHHHH",
src_port, dst_port, seq, ack, offset_flags, window, 0, 0
)
def describe(label, header):
src, dst, seq, ack, off_flags, window, _csum, _urg = struct.unpack(
"!HHIIHHHH", header
)
header_len = (off_flags >> 12) * 4
bits = off_flags & 0x3F
names = [FLAG_NAMES[i] for i in range(6) if bits & (1 << i)]
print(label)
print(" bytes on wire : %d (offset field says %d)" %
(len(header), header_len))
print(" ports :", src, "->", dst)
print(" seq / ack :", seq, "/", ack)
print(" window :", window)
print(" flags :", "+".join(names), "(0x%02x)" % bits)
# Client picks a random initial sequence number. Hard-coded here so the
# example is reproducible; a real stack would generate it unpredictably.
CLIENT_ISN = 1105843201
SERVER_ISN = 3221225472
syn = build_tcp_header(49152, 443, CLIENT_ISN, 0, 0x02, 64240)
syn_ack = build_tcp_header(443, 49152, SERVER_ISN, CLIENT_ISN + 1, 0x12, 65535)
ack = build_tcp_header(49152, 443, CLIENT_ISN + 1, SERVER_ISN + 1, 0x10, 64240)
describe("1. SYN (client -> server)", syn)
describe("2. SYN-ACK (server -> client)", syn_ack)
describe("3. ACK (client -> server)", ack)
print()
print("raw SYN hex :", syn.hex())
print("handshake costs 0 bytes of application data, 3 segments")1. SYN (client -> server)
bytes on wire : 20 (offset field says 20)
ports : 49152 -> 443
seq / ack : 1105843201 / 0
window : 64240
flags : SYN (0x02)
2. SYN-ACK (server -> client)
bytes on wire : 20 (offset field says 20)
ports : 443 -> 49152
seq / ack : 3221225472 / 1105843202
window : 65535
flags : SYN+ACK (0x12)
3. ACK (client -> server)
bytes on wire : 20 (offset field says 20)
ports : 49152 -> 443
seq / ack : 1105843202 / 3221225473
window : 64240
flags : ACK (0x10)
raw SYN hex : c00001bb41e9d401000000005002faf000000000
handshake costs 0 bytes of application data, 3 segments5-minute try-it
Extend build_tcp_header to produce a FIN+ACK (0x11) and an RST (0x04) segment and confirm the decoder names them correctly. Then work out on paper: if a segment carries 100 bytes of data starting at seq=x, what ACK number should come back, and how many sequence-number slots does a bare SYN itself consume?
One important caution
Reading the handshake as 'three packets means three round trips'. It is one and a half round trips, and the cost you actually pay before sending data is one RTT.
Trying to fix ephemeral-port exhaustion by shortening TIME_WAIT (or with tcp_tw_recycle, removed from modern Linux). TIME_WAIT is doing its job; the real bug is a client opening a fresh connection per request.
RFC 9293 - Transmission Control Protocol (TCP) — Computer Networking