Chapter 21 — TCP part 1: the three-way handshake
In one sentence
Before sending any data, TCP performs a three-packet greeting that establishes both sides' starting positions and confirms both directions work.
The problem
UDP just throws data. TCP promises reliable, ordered delivery. To make that promise, both sides need to agree on where the conversation starts and confirm they can actually hear each other.
The idea, in plain language
A phone call:
YOU: "Hello?" <-- can you hear me?
THEM: "Hello, yes I can hear you." <-- I hear you; can you hear me?
YOU: "Great, I hear you too." <-- confirmed. Now let's talk.
Three messages. After that, both sides know the other can hear them. Two wouldn't be enough (the first speaker would never know if their "hello" was heard).
How it actually works
CLIENT SERVER
(your laptop) (web server, LISTENing)
| |
| 1. SYN |
| "Let's talk. My byte numbering starts at X." |
|------------------------------------------------->|
| |
| 2. SYN + ACK |
| "OK. My numbering starts at Y. |
| And I acknowledge your X." |
|<-------------------------------------------------|
| |
| 3. ACK |
| "I acknowledge your Y." |
|------------------------------------------------->|
| |
|============ CONNECTION ESTABLISHED ==============|
| (data can now flow both ways) |
- SYN = "synchronise" — here's my starting sequence number.
- ACK = "acknowledge" — I received up to here.
- The middle packet does both jobs at once, which is why it's three packets and not four.
Cost: one round trip before any data moves. That's the first RTT in your Chapter 4 latency budget.
What's in the handshake besides hello
The SYN packets also negotiate options both sides will use:
| Option | What it agrees |
|---|---|
| MSS | The biggest chunk of data each will accept (Chapter 15) |
| Window scale | Lets the window be bigger than 64 KB (Chapter 24) — essential for speed |
| SACK permitted | Enables smarter loss recovery (Chapter 22) |
| Timestamps | Better round-trip measurement |
If a middlebox strips these options, the connection still works but is much slower. Worth remembering when a link is mysteriously slow.
The three outcomes of trying to connect
This is diagnostic gold — learn these three:
1. SYN -> SYN-ACK -> ACK = CONNECTED
Something is listening and let you in.
2. SYN -> RST = CONNECTION REFUSED
The machine is there and reachable, but NOTHING IS LISTENING
on that port. (RST = "reset" = go away.)
-> the service is down, or you have the wrong port
3. SYN -> (silence) -> SYN retry -> (silence) ... = TIMED OUT
Nothing came back at all.
-> a firewall is silently dropping, or the host is unreachable,
or the route is broken
Refused ≠ timeout. Refused means you reached the machine. Timeout means you possibly didn't. This distinction saves hours.
Practice (30 min) — capture your first handshake
This is the most important practice exercise in Part 5.
# Terminal 1: capture ONLY handshake packets to a site
sudo tcpdump -i any -n 'tcp port 443 and host example.com'
# Terminal 2:
curl -s https://example.com > /dev/null
You will see, right at the top:
IP 192.168.1.50.51000 > 93.184.216.34.443: Flags [S], seq 1234567, win 64240,
options [mss 1460,sackOK,TS val ...,nop,wscale 7], length 0
IP 93.184.216.34.443 > 192.168.1.50.51000: Flags [S.], seq 7654321, ack 1234568,
win 65535, options [mss 1460,sackOK,...], length 0
IP 192.168.1.50.51000 > 93.184.216.34.443: Flags [.], ack 7654322, win 502, length 0
Read it:
[S]= SYN.[S.]= SYN+ACK (the.means ACK).[.]= ACK only.seq 1234567= "my numbering starts here"ack 1234568= "I got your 1234567, send me 1234568 next" (note: +1)options [mss 1460, sackOK, wscale 7]= the negotiation described above
Congratulations — you have now seen the foundation of the internet with your own
eyes. Save this capture in notes.md.
Now produce the other two outcomes:
# CONNECTION REFUSED -- connect to a port with nothing on it, locally
sudo tcpdump -i any -n 'tcp port 9999' &
nc -v 127.0.0.1 9999
# you'll see: SYN then RST, and nc says "Connection refused"
# TIMEOUT -- connect to a filtered port on the internet
sudo tcpdump -i any -n 'host example.com and tcp port 81' &
nc -v -w 5 example.com 81
# you'll see: SYN ... SYN ... SYN (retries), no answer
Watch the retry timing on the timeout case: roughly 1 s, then 2 s, then 4 s. That's exponential backoff, and it's why a firewalled port takes so long to fail while a refused port fails instantly.
Build it in Go (20 min) — a handshake outcome classifier
The three outcomes above (connected, refused, timed out) each surface as a different error value in code. Production code that treats them all as "error" throws away the most useful diagnostic information it has. This program tells them apart:
// dialcheck: try a TCP connection and say WHICH of the handshake outcomes
// from Chapter 21 happened -- connected, refused, timed out, or something else.
//
// go run ./dialcheck example.com:443 127.0.0.1:9 example.com:81 nosuchhost.invalid:80
package main
import (
"context"
"errors"
"fmt"
"net"
"os"
"syscall"
"time"
)
func main() {
for _, target := range os.Args[1:] {
fmt.Printf("%-28s %s\n", target, check(target, 3*time.Second))
}
}
func check(target string, timeout time.Duration) string {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
start := time.Now()
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", target)
took := time.Since(start).Round(time.Millisecond)
if err == nil {
defer conn.Close()
return fmt.Sprintf("CONNECTED in %v (%s -> %s)", took, conn.LocalAddr(), conn.RemoteAddr())
}
var dnsErr *net.DNSError
var netErr net.Error
switch {
case errors.As(err, &dnsErr):
return fmt.Sprintf("DNS FAILURE (never sent a SYN): %v", dnsErr.Err)
case errors.Is(err, syscall.ECONNREFUSED):
return fmt.Sprintf("REFUSED in %v: host answered with RST -> nothing listening", took)
case errors.Is(err, syscall.EHOSTUNREACH), errors.Is(err, syscall.ENETUNREACH):
return fmt.Sprintf("UNREACHABLE in %v: no route, or an ICMP unreachable came back", took)
case errors.As(err, &netErr) && netErr.Timeout():
return fmt.Sprintf("TIMED OUT after %v: SYNs got no answer -> filtered/dropped", took)
default:
return fmt.Sprintf("OTHER after %v: %v", took, err)
}
}
$ go run ./dialcheck example.com:443 127.0.0.1:9 example.com:81 nosuchhost.invalid:80
example.com:443 CONNECTED in 93ms (192.168.1.2:62150 -> 172.66.147.243:443)
127.0.0.1:9 REFUSED in 0s: host answered with RST -> nothing listening
example.com:81 TIMED OUT after 3s: SYNs got no answer -> filtered/dropped
nosuchhost.invalid:80 DNS FAILURE (never sent a SYN): no such host
What to notice:
errors.Is(err, syscall.ECONNREFUSED)works because Go wraps the kernel's error number inside*net.OpError→*os.SyscallError.errors.Isanderrors.Asunwrap the chain. See the Go guide's error-handling chapter for the pattern.- Connect time ≈ 1 RTT. The 93 ms "CONNECTED" figure is the handshake
you captured above. Your app can measure RTT without ICMP at all, which
is useful where
pingis blocked. - The timeout is the caller's decision. Without the
context.WithTimeout, the kernel would keep retrying SYNs for about 2 minutes on Linux (net.ipv4.tcp_syn_retries = 6). Always put a deadline on dials.
Exercises:
- Lower the timeout to 500 ms and dial
example.com:81again. Then raise it to 10 s and count the SYN retransmissions withtcpdump. Do they follow 1 s, 2 s, 4 s? - Make
checkrun on a list of targets concurrently with async.WaitGroup. A filtered port shouldn't hold up the rest. Chapter 53'spolicytestlab is this idea grown up. - Add an IPv6-vs-IPv4 column: dial
tcp4andtcp6separately. Hosts whose IPv6 path is broken are why browsers use Happy Eyeballs (racing both).
Across the series: the Go plan's Day 32 TCP echo server builds the server side of this handshake, and Day 52 uses these states to debug CLOSE_WAIT leaks (see also Chapter 23 here).
Common confusions
- "Why not two packets?" After two, the client knows the server heard it, but the server doesn't know the client heard the reply. Three is the minimum for both to be sure.
- "Is the handshake encrypted?" No — TCP is plaintext. Encryption (TLS) is negotiated after, on top (Chapter 29).
- "What's a SYN flood?" An attack that sends many SYNs and never completes the handshake, filling the server's table of half-open connections. Modern defence: SYN cookies (Chapter 38).
Check yourself
- Why does the handshake need three packets rather than two?
- What do SYN and ACK mean?
- You send a SYN and get an RST. What does that tell you?
- You send a SYN and get nothing, three times. What does that tell you?
- Name two things negotiated during the handshake besides "hello".
Further reading
- Video (~12 min): "TCP 3-Way Handshake" — Practical Networking. Watch after doing the capture; it'll confirm what you saw.
- Article: "What is TCP?" and "TCP handshake" — Cloudflare Learning Center.
- RFC 9293 — the current TCP specification (August 2022; it replaced the original RFC 793 from 1981). Long, but section 3.5 on connection establishment is readable and authoritative.
- Interactive: Wireshark's "Follow TCP Stream" on your own capture — see Chapter 32.