Frontend Labs
⋯

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.Is and errors.As unwrap 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 ping is 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:

  1. Lower the timeout to 500 ms and dial example.com:81 again. Then raise it to 10 s and count the SYN retransmissions with tcpdump. Do they follow 1 s, 2 s, 4 s?
  2. Make check run on a list of targets concurrently with a sync.WaitGroup. A filtered port shouldn't hold up the rest. Chapter 53's policytest lab is this idea grown up.
  3. Add an IPv6-vs-IPv4 column: dial tcp4 and tcp6 separately. 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

  1. Why does the handshake need three packets rather than two?
  2. What do SYN and ACK mean?
  3. You send a SYN and get an RST. What does that tell you?
  4. You send a SYN and get nothing, three times. What does that tell you?
  5. 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.

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36