TCP Three-Way Handshake and Teardown, Captured

Terminal card showing a real tcpdump capture of a TCP SYN, SYN-ACK, ACK and a RST from a closed port

The three-way handshake is the first thing anyone learns about TCP and the last thing most people actually look at. SYN, SYN-ACK, ACK is easy to draw. What is harder, and far more useful, is reading a real capture and knowing at a glance whether the connection was refused, reset mid-stream, closed cleanly by the client, or closed cleanly by the server, because those four outcomes look almost identical in an application log and completely different on the wire.

Everything below came off a real segment between two Linux hosts. A full connection, a graceful teardown, a refused connection, and a UDP flow for contrast, all captured with tcpdump and decoded field by field. For the wider picture of where to capture and how to filter, start with the packet capture and analysis guide.

The lab

Two real machines on a switched VLAN: a Debian 13 host at 10.66.0.100 and a Kali box at 10.66.0.101, both bridged into a Cisco Modeling Labs topology so the traffic crosses an actual switch rather than a loopback. The listener on Kali is nothing more than nc -lnvp 8080. The client is a short Python script so the timing of the close is deliberate rather than accidental.

The whole connection in eleven packets

Absolute sequence numbers, because the relative ones tcpdump shows by default hide the thing you most want to see at the start.

j@llmbits:~$ sudo tcpdump -r /tmp/tcp2.pcap -nn -tttt -S
2026-08-29 14:24:36.308773 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [S], seq 16978942, win 64240, options [mss 1460,sackOK,TS val 3824367594 ecr 0,nop,wscale 7], length 0
2026-08-29 14:24:36.309169 IP 10.66.0.101.8080 > 10.66.0.100.48674: Flags [S.], seq 1564182302, ack 16978943, win 65160, options [mss 1460,sackOK,TS val 55565039 ecr 3824367594,nop,wscale 10], length 0
2026-08-29 14:24:36.309260 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [.], ack 1564182303, win 502, options [nop,nop,TS val 3824367595 ecr 55565039], length 0
2026-08-29 14:24:36.309387 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [P.], seq 16978943:16978969, ack 1564182303, win 502, options [nop,nop,TS val 3824367595 ecr 55565039], length 26: HTTP: GET /pinglabz HTTP/1.0
2026-08-29 14:24:36.309621 IP 10.66.0.101.8080 > 10.66.0.100.48674: Flags [.], ack 16978969, win 64, options [nop,nop,TS val 55565039 ecr 3824367595], length 0
2026-08-29 14:24:37.309699 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [F.], seq 16978969, ack 1564182303, win 502, options [nop,nop,TS val 3824368595 ecr 55565039], length 0
2026-08-29 14:24:37.310420 IP 10.66.0.101.8080 > 10.66.0.100.48674: Flags [F.], seq 1564182303, ack 16978970, win 64, options [nop,nop,TS val 55566040 ecr 3824368595], length 0
2026-08-29 14:24:37.310471 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [.], ack 1564182304, win 502, options [nop,nop,TS val 3824368596 ecr 55566040], length 0
2026-08-29 14:24:39.310285 IP 10.66.0.100.33542 > 10.66.0.101.9999: Flags [S], seq 1054064668, win 64240, options [mss 1460,sackOK,TS val 3681875109 ecr 0,nop,wscale 7], length 0
2026-08-29 14:24:39.310871 IP 10.66.0.101.9999 > 10.66.0.100.33542: Flags [R.], seq 0, ack 1054064669, win 0, length 0
2026-08-29 14:24:40.311526 IP 10.66.0.100.33285 > 10.66.0.101.9999: UDP, length 21

The handshake, one packet at a time

SYN

The client picks an initial sequence number of 16978942 and sends a segment with only the SYN flag set and no payload. Three things travel with it that decide how the rest of the connection behaves.

MSS 1460. The largest payload the client is willing to receive in one segment, derived from a 1500 byte MTU minus 20 bytes of IP and 20 of TCP. This is advertised, not negotiated: each side states its own and neither can override the other's.

Window scale 7. The client's real receive window is its advertised window shifted left by 7 bits, so a factor of 128. This option only appears in SYN and SYN-ACK packets, which has a practical consequence covered below.

sackOK and timestamps. Selective acknowledgment permission and the timestamp option that gives the stack a round-trip time sample on every segment. The ecr 0 is an empty echo reply, because there is nothing to echo yet.

SYN-ACK

Kali answers 396 microseconds later with Flags [S.], which is tcpdump's shorthand for SYN plus ACK. Its own initial sequence number is 1564182302, unrelated to the client's, and it acknowledges 16978943.

That is the client's ISN plus one. SYN consumes a sequence number even though it carries no data, and so does FIN. This is the entire reason the arithmetic in a TCP capture works out: an ACK is always "the next byte I expect", and SYN and FIN each occupy one slot in that space.

The server's window scale is 10, not 7. Both sides independently choose their own scale factor, and here the two Linux stacks picked different ones because their receive buffer sizes differ.

ACK

91 microseconds after that, the client sends a bare ACK. The connection is now ESTABLISHED on both sides, and the total cost was 0.5 milliseconds on a switched LAN.

Look at the window on this third packet: 502, down from 64240 in the SYN. Nothing has gone wrong. From the ACK onward the window field carries a scaled value, so the real window is 502 shifted left by 7, which is 64256 bytes. The unscaled 64240 in the SYN is the raw value because scaling cannot apply to the packet that negotiates it.

This is the practical consequence of window scale being SYN-only. If you start a capture on an existing connection, you never see the scale factors, and every window value in your capture is wrong by an unknown power of two. Wireshark will say as much. It is a good reason to start captures before the traffic you care about, not after.

Data and the delayed ACK that was not delayed

The client pushes 26 bytes with Flags [P.], PSH plus ACK, spanning sequence 16978943 to 16978969. The server acknowledges 16978969 in 234 microseconds.

Two details worth noticing. The PSH flag asks the receiving stack to hand data to the application immediately rather than buffering it, which is why interactive protocols set it on nearly every segment. And the server's window in that ACK is 64, scaled by 1024, so 65536 bytes: it read the data and its buffer is still empty.

The teardown

One second later (the client script sleeps deliberately), the connection closes in four packets:

2026-08-29 14:24:37.309699 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [F.], seq 16978969, ack 1564182303, win 502, options [nop,nop,TS val 3824368595 ecr 55565039], length 0
2026-08-29 14:24:37.310420 IP 10.66.0.101.8080 > 10.66.0.100.48674: Flags [F.], seq 1564182303, ack 16978970, win 64, options [nop,nop,TS val 55566040 ecr 3824368595], length 0
2026-08-29 14:24:37.310471 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [.], ack 1564182304, win 502, options [nop,nop,TS val 3824368596 ecr 55566040], length 0

Textbooks describe a four-way close: FIN, ACK, FIN, ACK. This capture shows three packets, and that is the normal case, not an anomaly. The server had nothing left to send, so it combined its acknowledgment of the client's FIN with its own FIN into a single segment. You only see a genuine four-packet close when the server still has data in flight and needs to keep its half of the connection open, which is the half-close condition.

The sequence arithmetic confirms FIN consumes a byte. The client's FIN sits at 16978969, the last byte it sent was 16978968, and the server acknowledges 16978970. One number for the FIN itself.

After that third packet the client sits in TIME_WAIT for twice the maximum segment lifetime, typically 60 seconds on Linux. That is invisible on the wire and highly visible in ss -tan, and it is why a machine that opens and closes thousands of short connections runs out of ephemeral ports long before it runs out of anything else.

The same capture with relative sequence numbers

j@llmbits:~$ sudo tcpdump -r /tmp/tcp2.pcap -nn -tttt
2026-08-29 14:24:36.308773 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [S], seq 16978942, win 64240, options [mss 1460,sackOK,TS val 3824367594 ecr 0,nop,wscale 7], length 0
2026-08-29 14:24:36.309169 IP 10.66.0.101.8080 > 10.66.0.100.48674: Flags [S.], seq 1564182302, ack 16978943, win 65160, options [mss 1460,sackOK,TS val 55565039 ecr 3824367594,nop,wscale 10], length 0
2026-08-29 14:24:36.309260 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [.], ack 1, win 502, options [nop,nop,TS val 3824367595 ecr 55565039], length 0
2026-08-29 14:24:36.309387 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [P.], seq 1:27, ack 1, win 502, options [nop,nop,TS val 3824367595 ecr 55565039], length 26: HTTP: GET /pinglabz HTTP/1.0
2026-08-29 14:24:36.309621 IP 10.66.0.101.8080 > 10.66.0.100.48674: Flags [.], ack 27, win 64, options [nop,nop,TS val 55565039 ecr 3824367595], length 0
2026-08-29 14:24:37.309699 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [F.], seq 27, ack 1, win 502, options [nop,nop,TS val 3824368595 ecr 55565039], length 0
2026-08-29 14:24:37.310420 IP 10.66.0.101.8080 > 10.66.0.100.48674: Flags [F.], seq 1, ack 28, win 64, options [nop,nop,TS val 55566040 ecr 3824368595], length 0
2026-08-29 14:24:37.310471 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [.], ack 2, win 502, options [nop,nop,TS val 3824368596 ecr 55566040], length 0

The SYN and SYN-ACK are identical in both views, because there is no earlier packet to be relative to. From the third packet on, tcpdump normalizes each side's ISN to zero unless you pass -S. The relative view is far easier to read for flow analysis: 26 bytes sent, acked at 27, FIN at 27, acked at 28. Use it by default and switch to -S when you need to correlate two captures of the same connection taken at different points, because only the absolute numbers will match.

The SYN, byte for byte

j@llmbits:~$ sudo tcpdump -r /tmp/tcp2.pcap -nn -c 2 -XX
14:24:36.308773 IP 10.66.0.100.48674 > 10.66.0.101.8080: Flags [S], seq 16978942, win 64240, options [mss 1460,sackOK,TS val 3824367594 ecr 0,nop,wscale 7], length 0
	0x0000:  000c 296f c4ac 000c 29b1 cc47 0800 4500  ..)o....)..G..E.
	0x0010:  003c f719 4000 4006 2e56 0a42 0064 0a42  .<..@.@..V.B.d.B
	0x0020:  0065 be22 1f90 0103 13fe 0000 0000 a002  .e."............
	0x0030:  faf0 157b 0000 0204 05b4 0402 080a e3f3  ...{............
	0x0040:  37ea 0000 0000 0103 0307                 7.........
14:24:36.309169 IP 10.66.0.101.8080 > 10.66.0.100.48674: Flags [S.], seq 1564182302, ack 16978943, win 65160, options [mss 1460,sackOK,TS val 55565039 ecr 3824367594,nop,wscale 10], length 0
	0x0000:  000c 29b1 cc47 000c 296f c4ac 0800 4500  ..)..G..)o....E.
	0x0010:  003c 0000 4000 4006 2570 0a42 0065 0a42  .<..@.@.%p.B.e.B
	0x0020:  0064 1f90 be22 5d3b 871e 0103 13ff a012  .d..."];........
	0x0030:  fe88 62ec 0000 0204 05b4 0402 080a 034f  ..b............O
	0x0040:  daef e3f3 37ea 0103 030a                 ....7.....
0x0e
45 00
IPv4, 20-byte header, no TOS marking.
0x14
4000
The Don't Fragment bit. Set on essentially all modern TCP, which is what makes path MTU discovery work and what makes a black-holed ICMP unreachable so painful.
0x17
06
Protocol 6, TCP.
0x22, 0x24
be22, 1f90
Source port 48674 (ephemeral) and destination port 8080.
0x26
0103 13fe
Sequence number 16978942.
0x2a
0000 0000
Acknowledgment number, zero because the ACK flag is not set.
0x2e
a0
Data offset in the top nibble: 0xa is 10 words, so a 40-byte TCP header. 20 bytes of that is options.
0x2f
02
The flags byte. Bit 1 is SYN. The SYN-ACK below carries 0x12: SYN plus ACK.
0x30
faf0
Window 64240, unscaled.
0x36
0204 05b4
Option 2, length 4, value 0x05b4 = 1460. MSS.
0x3a
0402
Option 4, length 2. SACK permitted, no value needed.
0x3c
080a ...
Option 8, length 10. Timestamp value 3824367594, echo reply 0.
0x46
0103 0307
A nop for alignment, then option 3, length 3, shift count 7. Window scale.

The SYN-ACK differs in exactly the places you would predict: a012 instead of a002 for the flags byte pair, a populated acknowledgment field at 0x2a (0103 13ff, the client's ISN plus one), and 0103 030a at the end for a shift of 10 rather than 7.

Both segments have a 40-byte TCP header rather than the minimum 20, because every one of those options only ever appears here. That is the concrete cost of the handshake, and it is why TCP Fast Open and QUIC exist.

Refused: what a RST actually looks like

14:24:39.310285 IP 10.66.0.100.33542 > 10.66.0.101.9999: Flags [S], seq 1054064668, win 64240, options [mss 1460,sackOK,TS val 3681875109 ecr 0,nop,wscale 7], length 0
14:24:39.310871 IP 10.66.0.101.9999 > 10.66.0.100.33542: Flags [R.], seq 0, ack 1054064669, win 0, length 0

Nothing is listening on 9999. The response arrives in 586 microseconds and it is [R.], RST plus ACK. Three details are diagnostic.

Sequence 0 and window 0. There is no connection, so there is no sequence space and no receive buffer. A RST generated for a non-existent connection always looks like this.

It acknowledges 1054064669. The client's ISN plus one, again because SYN consumes a sequence number. The ACK flag is set precisely so the client can verify this RST belongs to its connection attempt and is not injected by someone guessing.

It is fast. Sub-millisecond, from the host itself. That timing is how you distinguish a closed port from a filtered one: a firewall drop gives you silence and a client-side timeout, while a closed port gives you an immediate RST. It is the same distinction Nmap reports as closed versus filtered, and it is worth reading alongside how UDP scanning infers state, because UDP has no equivalent signal.

A RST in the middle of an established connection means something different again: an application that closed a socket with unread data, a firewall tearing down an idle session, or a load balancer dropping a backend. Same flag, entirely different investigation.

UDP for contrast

j@llmbits:~$ sudo tcpdump -r /tmp/udp.pcap -nn -tttt -v
2026-08-29 14:25:43.012551 IP (tos 0x0, ttl 64, id 49038, offset 0, flags [DF], proto UDP (17), length 49)
    10.66.0.100.51429 > 10.66.0.101.9999: UDP, length 21
2026-08-29 14:25:43.013080 IP (tos 0xc0, ttl 64, id 2160, offset 0, flags [none], proto ICMP (1), length 77)
    10.66.0.101 > 10.66.0.100: ICMP 10.66.0.101 udp port 9999 unreachable, length 57
	IP (tos 0x0, ttl 64, id 49038, offset 0, flags [DF], proto UDP (17), length 49)
    10.66.0.100.51429 > 10.66.0.101.9999: UDP, length 21

Same destination port, same closed state, completely different shape. One datagram goes out with no handshake, no sequence number, no window and no options: the entire UDP header is eight bytes of source port, destination port, length and checksum. The application called sendto() and the packet was on the wire.

The error does not come back inside the transport. It comes back as an ICMP port unreachable from a different protocol entirely, and notice what it carries: the IP header and first bytes of the original datagram, quoted back so the sending host can work out which socket to fail. TCP does not need that, because the RST carries the four-tuple in its own header.

The practical consequence is the one every firewall engineer has met. Block ICMP and TCP still tells you a port is closed, while UDP tells you nothing at all and the sender waits for a timeout. That asymmetry is the whole reason UDP scans are slow and UDP troubleshooting is awkward. The full comparison, including where each transport is the right choice, is in understanding TCP vs UDP.

Filters and flags worth memorizing

# one connection, both directions
sudo tcpdump -i ens224 -nn 'host 10.66.0.101 and port 8080'

# SYNs only, to see who is connecting to what
sudo tcpdump -i ens224 -nn 'tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack == 0'

# resets only, to catch what is killing sessions
sudo tcpdump -i ens224 -nn 'tcp[tcpflags] & tcp-rst != 0'

# absolute sequence numbers, full packet, to a file
sudo tcpdump -i ens224 -nn -S -s0 -w tcp.pcap 'host 10.66.0.101'

The tcp[tcpflags] syntax indexes directly into the TCP header, and the named constants (tcp-syn, tcp-ack, tcp-fin, tcp-rst, tcp-push) keep it readable. In Wireshark the same filters are tcp.flags.syn == 1 && tcp.flags.ack == 0 and tcp.flags.reset == 1. More on the syntax in tcpdump for network engineers.

Reading a TCP capture, in order

1. Is there a SYN-ACK?
No answer at all means filtered or unreachable. A RST means the port is closed. A SYN-ACK means the listener exists.
2. How long did it take?
The SYN to SYN-ACK gap is your cleanest round-trip measurement, uncontaminated by application processing.
3. What did the options negotiate?
MSS, window scale and SACK are all decided here and nowhere else. A missing scale option explains impossible window numbers later.
4. Who spoke first?
A handshake that completes and then goes silent points at the application, not the network.
5. How did it end?
FIN from the client is a normal close. FIN from the server first often means a timeout. RST means something gave up.
6. Does the arithmetic hold?
Every ACK should equal the last byte received plus one, plus one more for each SYN and FIN. A gap means retransmission or loss.

Key takeaways

  • SYN and FIN each consume one sequence number even though they carry no data. That single rule makes the whole ACK arithmetic readable.
  • Window scale is negotiated only in the SYN and SYN-ACK. Start a capture mid-connection and every window value you see is off by an unknown factor.
  • A three-packet close is normal. The four-packet version only appears when the server still has data to send.
  • A RST with sequence 0, window 0 and an ACK of the client's ISN plus one is the signature of a closed port, and it arrives in microseconds.
  • UDP reports errors through ICMP, not through the transport. Block ICMP and that signal disappears entirely.
  • The handshake costs 40-byte TCP headers on the first two packets, because every option lives there and nowhere else.

This teardown is part of the PingLabz packet capture cluster. The packet capture and analysis guide covers tap points, filter languages and capture hygiene, and data encapsulation in TCP/IP walks the same bytes from the other direction, from application data down to the frame.

Read next