Diagrams of the TCP handshake are everywhere. Captures of one, annotated so that every number is explained, are rarer, and the numbers are where the understanding is. This post takes a single curl request to a local web server, captures it with tcpdump, and walks through all fourteen packets. Along the way it covers what the headers mean, how sequence numbers work, what the window field means, and what happens on a real network that cannot happen on loopback.

The capture

Everything here was recorded on one machine with three terminals' worth of commands:

  1. Start a web server on the loopback interface, serving a one-line HTML file:

    python3 -m http.server 8080 --bind 127.0.0.1 --directory ./www
    
  2. Start capturing on lo0 (the loopback interface on macOS; lo on Linux), filtered to the server port, and write raw packets to a file so they can be re-read with different options later. tcpdump needs access to the BPF devices, which means root or membership of the access_bpf group on macOS.

    tcpdump -i lo0 -nn -s 0 port 8080 -w capture.pcap
    
  3. Make one request with verbose output and timestamps, then stop the capture with Ctrl-C:

    curl -v --trace-time http://127.0.0.1:8080/index.html -o /dev/null
    
  4. Read the capture back. -nn keeps ports numeric, -S switches from relative to absolute sequence numbers, and -ttttt prints the time since the first packet instead of the time of day:

    tcpdump -nn -r capture.pcap            # relative seq/ack, time of day
    tcpdump -nn -S -r capture.pcap -ttttt  # absolute seq/ack, time since first packet
    

The result was 14 packets in 16.8 ms. Here is the whole exchange with relative sequence numbers; the rest of the post refers back to it by line.

01:23:31.990224 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [S], seq 420074069, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 2025798566 ecr 0,sackOK,eol], length 0
01:23:31.990313 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [S.], seq 406532616, ack 420074070, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 3954684158 ecr 2025798566,sackOK,eol], length 0
01:23:31.990330 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [.], ack 1, win 6380, options [nop,nop,TS val 2025798566 ecr 3954684158], length 0
01:23:31.990342 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [.], ack 1, win 6380, options [nop,nop,TS val 3954684158 ecr 2025798566], length 0
01:23:31.996118 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [P.], seq 1:89, ack 1, win 6380, options [nop,nop,TS val 2025798572 ecr 3954684158], length 88: HTTP: GET /index.html HTTP/1.1
01:23:31.996165 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [.], ack 89, win 6379, options [nop,nop,TS val 3954684164 ecr 2025798572], length 0
01:23:32.002470 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [P.], seq 1:186, ack 89, win 6379, options [nop,nop,TS val 3954684170 ecr 2025798572], length 185: HTTP: HTTP/1.0 200 OK
01:23:32.002519 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [.], ack 186, win 6378, options [nop,nop,TS val 2025798579 ecr 3954684170], length 0
01:23:32.002545 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [P.], seq 186:248, ack 89, win 6379, options [nop,nop,TS val 3954684171 ecr 2025798579], length 62: HTTP
01:23:32.002551 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [.], ack 248, win 6378, options [nop,nop,TS val 2025798579 ecr 3954684171], length 0
01:23:32.002562 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [F.], seq 248, ack 89, win 6379, options [nop,nop,TS val 3954684171 ecr 2025798579], length 0
01:23:32.002579 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [.], ack 249, win 6378, options [nop,nop,TS val 2025798579 ecr 3954684171], length 0
01:23:32.006891 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [F.], seq 89, ack 249, win 6378, options [nop,nop,TS val 2025798583 ecr 3954684171], length 0
01:23:32.006974 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [.], ack 90, win 6379, options [nop,nop,TS val 3954684175 ecr 2025798583], length 0
One HTTP/1.1 request over loopback, as captured by tcpdump relative sequence numbers curl 127.0.0.1:58525 http.server 127.0.0.1:8080 handshake data teardown [S] seq 420074069, win 65535, mss 16344, wscale 6, sackOK, TS [S.] seq 406532616, ack 420074070, win 65535, mss 16344, wscale 6 [.] ack 1, win 6380 (6380 × 2^6 = 408,320 bytes) [P.] seq 1:89, ack 1, length 88 — GET /index.html HTTP/1.1 [.] ack 89 [P.] seq 1:186, ack 89, length 185 — HTTP/1.0 200 OK + headers [.] ack 186 [P.] seq 186:248, length 62 — the HTML body [.] ack 248 [F.] seq 248, ack 89 — server closes first (HTTP/1.0 semantics) [.] ack 249 (a FIN consumes one sequence number) [F.] seq 89, ack 249 — curl closes its side 4 ms later [.] ack 90 14 packets, 16.8 ms wall clock. The 4th packet (a redundant server ACK, a loopback quirk) is omitted.
The same capture as a sequence diagram. Blue messages carry connection state; black ones carry data.

Reading the flags

tcpdump prints flags in a compact notation. The table covers the ones you will see in almost every capture.

Notation Flag Meaning
[S] SYN synchronise: "here is my initial sequence number", opens a connection
[S.] SYN + ACK the server's half of the handshake; the dot is ACK
[.] ACK only acknowledgement with no payload
[P.] PSH + ACK data segment; PSH asks the receiver to hand the data to the application without waiting
[F.] FIN + ACK "I have no more data to send"; each side sends one
[R] RST reset: abort the connection, or "nobody is listening on that port"
[U] URG urgent pointer in use; effectively obsolete
[E], [W] ECE, CWR explicit congestion notification echo and congestion-window-reduced

Packets 1 to 3: the handshake

The client sends [S] with seq 420074069. That is its initial sequence number (ISN), chosen randomly so that stale segments from an earlier connection on the same ports cannot be mistaken for this one. The options are the interesting part:

  • mss 16344 is the maximum segment size the sender can accept. On a real interface this is typically 1460 (a 1500-byte Ethernet MTU minus 40 bytes of IP and TCP headers). The loopback interface on this machine has a 16384-byte MTU, hence 16344.
  • wscale 6 says "multiply my advertised window by 26=64". More on this below.
  • sackOK enables selective acknowledgements, so a receiver can say "I have bytes 1 to 1000 and 2001 to 3000" instead of only "I have up to 1000".
  • TS val ... ecr ... are timestamps, used for round-trip measurement and to protect against sequence-number wraparound on fast links.

The server replies [S.] with its own ISN, seq 406532616, and ack 420074070: the client's ISN plus one, because a SYN occupies one sequence number even though it carries no data. The client's [.] ack 1 completes the handshake. From here tcpdump shows relative numbers, so ack 1 means "I expect the first data byte", which is absolute 406532617; adding -S prints the absolute values if you prefer them.

The fourth packet, a server [.] ack 1 that duplicates information already exchanged, is a loopback peculiarity of the local stack and not part of the protocol; I left it in the listing because real captures are untidy and it is worth recognising noise when you see it.

Packets 5 to 10: data

curl sends its request in one 88-byte segment, seq 1:89, meaning bytes 1 through 88 inclusive (the range is half-open). The server acknowledges with ack 89: "I have everything up to 88, send 89 next". Sequence numbers count bytes, not packets, which is what makes retransmission and reassembly work when segments are lost or reordered.

The response arrives in two segments because the server made two write() calls: 185 bytes of status line and headers (seq 1:186), then 62 bytes of body (seq 186:248). Both carry PSH. The client acknowledges each one. Consecutive packets are less than 0.1 ms apart except in three places. Two of the gaps are on the client: 5.8 ms between the handshake and the request, and 4.3 ms before its FIN, both time spent inside curl. The third, 6.3 ms between the request and the response headers, is the Python server doing its work, not the network.

GET /index.html HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: curl/8.20.0
Accept: */*

That request is exactly 88 bytes including the blank line's \r\n; the capture and the application agree.

Packets 11 to 14: teardown

HTTP/1.0, which the Python server speaks, closes the connection after each response, and the server closes first: [F.] seq 248. A FIN, like a SYN, consumes one sequence number, so the client's ack 249 acknowledges it. Four milliseconds later curl sends its own [F.] seq 89 and the server acknowledges with ack 90. Each direction is closed independently; that is why there are two FINs and two ACKs, and why a connection can be half-closed with one side still sending.

The side that closes first (the server, here) enters TIME_WAIT and keeps the port pair reserved for a while, so that a late segment from this connection cannot be delivered into a new connection reusing the same four-tuple. Busy servers that close connections themselves accumulate TIME_WAIT entries; it is a well-known reason to let clients close first, or to keep connections alive.

TIP

Capture to a file with -w and analyse later. The same .pcap opens in Wireshark, where "Follow TCP Stream" reconstructs both directions of the conversation, and it lets you re-run tcpdump -r with -S, -v or a different filter without repeating the experiment.

Flow control: the window

Every segment advertises a receive window: how many more bytes the sender of that segment is willing to accept. In the handshake both sides say win 65535, the maximum the 16-bit field can hold. After the handshake the client says win 6380, which looks like a drastic reduction until you apply the scale factor negotiated in the SYN:

6380×26=408,320 bytes.

Window scaling exists because 65,535 bytes is far too small for modern links. The amount of data that can be in flight on a path is the bandwidth-delay product; for a 100 Mbit/s link with a 50 ms round trip,

100×106 bit/s×0.050 s8=625,000 bytes,

about ten times what an unscaled window allows. Without scaling, such a link could never be more than a tenth utilised by one connection, regardless of how fast either host is.

Flow control protects the receiver: it stops a fast sender from overrunning a slow application. It says nothing about the network in between.

Congestion control: what loopback cannot show

The network is protected by a second, sender-side limit: the congestion window. A new connection does not start at the receiver's advertised 408 KB; it starts small (ten segments is the common initial window) and grows. In slow start the window doubles every round trip, so the first RTT sends 10 segments, the second 20, then 40, 80. When a loss is detected the sender treats it as a congestion signal, shrinks the window, and switches to congestion avoidance, where growth is gradual.

The classic (Reno) rule halves the window on loss and then adds one segment per round trip. CUBIC, the default in Linux and most other systems today, replaces that linear growth with a cubic function of the time since the last loss: the window grows quickly at first, flattens as it approaches the size where the loss happened (a plateau that probes gently for the old capacity), then accelerates again if no loss occurs. On loss it multiplies the window by 0.7 instead of 0.5. The result is both more aggressive on fast long-distance links, where Reno takes minutes to refill a large pipe one segment per RTT, and fairer between flows with different round-trip times, because the growth is a function of wall-clock time rather than of RTTs.

Idealised congestion window over 200 round trips: both curves double during slow start, drop at a single loss event, then Reno climbs linearly while CUBIC rises quickly, plateaus near the pre-loss window, and rises again
An idealised model of the congestion window after one loss, Reno-style versus CUBIC. This is a model, not a measurement: on loopback there is no loss and no meaningful RTT to observe.

None of this appears in the capture above because loopback has effectively zero latency and no loss; the entire exchange fits in the initial window with room to spare. To see slow start, capture a large download from a distant server and plot seq against time; the doubling shows up as a staircase whose steps get taller.

The same request from Python

The capture came from curl, but there is nothing special about it. A socket, one sendall, and a recv loop produce the same sequence of packets, and reading the code next to the capture makes the mapping between application calls and segments concrete:

import socket

with socket.create_connection(("127.0.0.1", 8080), timeout=5) as s:
    # One write: the whole request fits in a single segment (69 bytes; curl's was 88).
    s.sendall(b"GET /index.html HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nConnection: close\r\n\r\n")
    chunks = []
    while True:
        data = s.recv(4096)   # returns b"" once the server's FIN has arrived
        if not data:
            break
        chunks.append(data)
response = b"".join(chunks)
head, _, body = response.partition(b"\r\n\r\n")
print(head.decode())
print("body bytes:", len(body))

create_connection is the handshake (packets 1 to 3). sendall is packet 5. Each recv that returns data corresponds to one or more of the server's [P.] segments, and the b"" that ends the loop is the server's FIN. Leaving the with block closes the socket, which is packet 13.

The capture with absolute sequence numbers (tcpdump -S)
 00:00:00.000000 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [S], seq 420074069, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 2025798566 ecr 0,sackOK,eol], length 0
 00:00:00.000089 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [S.], seq 406532616, ack 420074070, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 3954684158 ecr 2025798566,sackOK,eol], length 0
 00:00:00.000106 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [.], ack 406532617, win 6380, options [nop,nop,TS val 2025798566 ecr 3954684158], length 0
 00:00:00.000118 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [.], ack 420074070, win 6380, options [nop,nop,TS val 3954684158 ecr 2025798566], length 0
 00:00:00.005894 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [P.], seq 420074070:420074158, ack 406532617, win 6380, options [nop,nop,TS val 2025798572 ecr 3954684158], length 88: HTTP: GET /index.html HTTP/1.1
 00:00:00.005941 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [.], ack 420074158, win 6379, options [nop,nop,TS val 3954684164 ecr 2025798572], length 0
 00:00:00.012246 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [P.], seq 406532617:406532802, ack 420074158, win 6379, options [nop,nop,TS val 3954684170 ecr 2025798572], length 185: HTTP: HTTP/1.0 200 OK
 00:00:00.012295 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [.], ack 406532802, win 6378, options [nop,nop,TS val 2025798579 ecr 3954684170], length 0
 00:00:00.012321 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [P.], seq 406532802:406532864, ack 420074158, win 6379, options [nop,nop,TS val 3954684171 ecr 2025798579], length 62: HTTP
 00:00:00.012327 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [.], ack 406532864, win 6378, options [nop,nop,TS val 2025798579 ecr 3954684171], length 0
 00:00:00.012338 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [F.], seq 406532864, ack 420074158, win 6379, options [nop,nop,TS val 3954684171 ecr 2025798579], length 0
 00:00:00.012355 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [.], ack 406532865, win 6378, options [nop,nop,TS val 2025798579 ecr 3954684171], length 0
 00:00:00.016667 IP 127.0.0.1.58525 > 127.0.0.1.8080: Flags [F.], seq 420074158, ack 406532865, win 6378, options [nop,nop,TS val 2025798583 ecr 3954684171], length 0
 00:00:00.016750 IP 127.0.0.1.8080 > 127.0.0.1.58525: Flags [.], ack 420074159, win 6379, options [nop,nop,TS val 3954684175 ecr 2025798583], length 0

Check one line against the relative listing: the request is seq 420074070:420074158, and 420074158−420074070=88 bytes, the same length 88. The client's ISN was 420074069, so the first data byte is ISN + 1, exactly as the handshake section claimed.

What curl -v saw at the same time
01:23:31.989594 *   Trying 127.0.0.1:8080...
01:23:31.990342 * Established connection to 127.0.0.1 (127.0.0.1 port 8080) from 127.0.0.1 port 58525
01:23:31.995012 * using HTTP/1.x
01:23:31.996124 > GET /index.html HTTP/1.1
01:23:31.996124 > Host: 127.0.0.1:8080
01:23:31.996124 > User-Agent: curl/8.20.0
01:23:31.996124 > Accept: */*
01:23:31.996124 >
01:23:31.997776 * Request completely sent off
01:23:32.002576 * HTTP 1.0, assume close after body
01:23:32.002717 < HTTP/1.0 200 OK
01:23:32.002848 < Server: SimpleHTTP/0.6 Python/3.14.3
01:23:32.002969 < Date: Sun, 07 Jun 2026 18:23:32 GMT
01:23:32.003067 < Content-type: text/html
01:23:32.003178 < Content-Length: 62
01:23:32.003924 < Last-Modified: Sun, 07 Jun 2026 18:23:28 GMT
01:23:32.004509 <
01:23:32.006761 * shutting down connection #0

"Established connection" is logged at 31.990342, the same microsecond as the fourth packet in the capture: curl returns from connect() as soon as the handshake completes. The ephemeral source port, 58525, matches too. Content-Length: 62 is the body segment's length 62.

Further reading