The Three-Way Handshake
SYN, SYN-ACK, ACK: three segments in which each side proposes its initial sequence number and hears the other’s acknowledged, options are agreed, and the server moves the connection from a half-open queue to the accept queue — one full round trip before a single byte of data, which is the cost every short connection pays.
The problem
Three segments
The client sends a segment with the SYN flag and its initial sequence number (ISN), say 1,000,000 — "I want to talk; my bytes start here." The server replies with SYN and ACK set: its own ISN, say 5,000,000, and an acknowledgment number of 1,000,001 — "I heard you; your next byte is 1,000,001; mine start here." The client replies with ACK 5,000,001. The connection is ESTABLISHED on both sides, and the client’s first data segment can travel in the same packet as that final ACK.
The SYN segments also carry options that only exist at this moment: the MSS each side will accept, window scale (Flow Control: The Receive Window — it cannot be negotiated later, which is why an old middlebox stripping it caps a connection at 64 kB windows forever), SACK-permitted (Sequence Numbers, ACKs and Reassembly) and timestamps. The handshake is where the connection’s capabilities are fixed.
Notice that a SYN consumes one sequence number even though it carries no data (hence the ACK of ISN+1). So does a FIN. That bookkeeping is what lets the SYN itself be acknowledged and retransmitted like any other byte.
- Client → SYN, seq=1,000,000options: MSS 1460, wscale 7, SACK ok, timestamps; state SYN_SENT↓
- Server → SYN+ACK, seq=5,000,000, ack=1,000,001its own options; state SYN_RECV; entry in the SYN queue↓
- Client → ACK, ack=5,000,001state ESTABLISHED; data may ride along↓
- Server: ESTABLISHEDmoved to the accept queue; accept() returns a new socket↓
- Data flowsfirst byte is seq 1,000,001
Why three, and why random
Two messages are not enough. After SYN and SYN-ACK the client knows the server is alive and knows both ISNs; the server knows only that *someone* sent a SYN — it has not heard its own ISN acknowledged, so it cannot know the client received the SYN-ACK or that the client is who the source address says. The third message closes that gap: each side has now sent its ISN and had it acknowledged. Four messages (SYN, ACK, SYN, ACK) would be correct too; TCP merges the middle two.
The three-way exchange also defends against old duplicate SYNs. A delayed SYN from a previous connection can arrive at a server; the server replies SYN-ACK; the client, which has no such connection, sends RST instead of ACK, and the half-open entry is discarded. Without the third step the server would create a connection nobody wanted.
ISNs are randomised (RFC 6528 — a hash of the 4-tuple and a secret, plus a clock) for two reasons: so that segments from an old incarnation of the same 4-tuple cannot be mistaken for the new one, and so that an attacker who cannot see the traffic cannot guess the sequence numbers and inject data or forge a connection from a spoofed address. The offset in every Wireshark trace where the relative sequence number starts at 0 is hiding a 32-bit random value.
The server side: two queues behind listen()
A server calls listen(fd, backlog) and then accept() in a loop. Between the SYN arriving and accept() returning, the kernel keeps the connection in one of two queues. The SYN queue (half-open connections, state SYN_RECV) holds entries that have been sent a SYN-ACK and are awaiting the final ACK. When the ACK arrives the entry moves to the accept queue (fully established, waiting for the application), and accept() pops from there.
On Linux the accept queue length is min(backlog, net.core.somaxconn) — somaxconn defaults to 4096 since kernel 5.4, and was 128 before. If the accept queue is full when the final ACK arrives, the default behaviour is to *drop the ACK* (tcp_abort_on_overflow = 0): the client believes it is connected and sends data, the server’s SYN-ACK is retransmitted, and the client sees a stall of one to several seconds rather than an error. A server that is too slow to call accept() — its event loop blocked, its thread pool exhausted — produces exactly this signature: ss -ltn shows Recv-Q at or above Send-Q on the listening socket, and nstat -az TcpExtListenOverflows climbs.
$ ss -ltn State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 4097 4096 0.0.0.0:8080 0.0.0.0:* # ^ established connections waiting for accept() ^ queue limit $ nstat -az | grep -E 'ListenOverflows|ListenDrops' TcpExtListenOverflows 18342 0.0 TcpExtListenDrops 18342 0.0
SYN floods and SYN cookies
The SYN queue is the attack surface. An attacker sends SYNs from spoofed source addresses and never completes the handshake; every one costs the server an entry and a SYN-ACK retransmission schedule (five retries over about a minute by default on Linux). Fill the queue and legitimate SYNs are dropped: the service is down while the server is idle. That is a SYN flood, and it is thirty years old.
The defence is SYN cookies: when the queue is under pressure, the server stops storing state and instead encodes the essentials (a timestamp, the MSS, a hash of the 4-tuple and a secret) into its ISN. If a genuine ACK ever returns, the server can reconstruct the entry from the acknowledged number and create the connection; if not, nothing was stored. The cost is that options that do not fit in the cookie — window scale and SACK, unless timestamps are on — are lost for that connection. Linux enables cookies automatically on overflow (net.ipv4.tcp_syncookies = 1) and records it in nstat as TcpExtSyncookiesSent.
What the handshake costs, and how to avoid paying it
One round trip before the first byte. On a LAN that is 0.2 ms and irrelevant; across a continent it is 60–80 ms; across an ocean, 150 ms or more — and then The TLS Handshake adds another round trip on top (TLS 1.3; two for 1.2), and Congestion Control: Protecting the Network starts the connection at ten segments, so a 100 kB response needs several more. A request that transfers 300 bytes can spend 300 ms in setup and 1 ms in transfer. Latency, not bandwidth, is what short connections pay for — Bandwidth vs Latency.
The remedies are all forms of *not doing it again*. Keep-Alive and Connection Reuse reuses one connection for many HTTP requests; Connection Pooling keeps a set of established connections to a database or a service ready. TCP Fast Open (RFC 7413) lets a client that has previously connected send data in the SYN itself, using a cookie the server issued last time — it works, is supported by Linux on both ends, and is rarely enabled on the public internet because middleboxes drop unfamiliar SYNs. QUIC (HTTP/3 and QUIC) folds the transport and TLS handshakes into one round trip and offers 0-RTT resumption, which is the same idea done in user space where middleboxes cannot see it.
Refused versus timed out
Two handshake failures look different and mean different things. If the SYN reaches a host where *nothing is listening* on that port, the kernel answers with RST, and connect() fails at once with ECONNREFUSED. That is good news: the network is fine, the host is up, and the process is not running or is bound to a different port or address — the connection-refused-nothing-listening challenge. If the SYN (or the SYN-ACK) is *dropped* — by a firewall, a wrong route, a dead host — nothing comes back, the client retransmits the SYN with doubling delays (1, 2, 4, 8, 16, 32 s on Linux, tcp_syn_retries = 6) and gives up after about two minutes with ETIMEDOUT.
So: instant refusal → look at the server process; a long hang → look at the path (Why Can’t I Connect?, Firewalls). A firewall that *rejects* rather than *drops* will also produce an instant failure, with an ICMP unreachable rather than an RST; tcpdump distinguishes them.
Key points
- SYN (ISN), SYN-ACK (ISN + ack), ACK: each side’s starting sequence number is sent and acknowledged; data can ride on the third segment.
- Options — MSS, window scale, SACK, timestamps — are fixed at the handshake and cannot be added later.
- Two messages cannot confirm the server’s ISN was received; three can, and the third also kills old duplicate SYNs.
- The server keeps a SYN queue (half-open) and an accept queue (established, waiting for accept()); overflow drops the final ACK and the client stalls, not errors.
- SYN floods fill the SYN queue with spoofed half-open connections; SYN cookies encode the state in the ISN so nothing is stored.
- The handshake costs one RTT before any data; keep-alive, pooling, TFO and QUIC exist to avoid paying it per request.
- RST → refused at once (nothing listening); silence → timeout after ~2 minutes (dropped on the path).
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why not send data with the first SYN?
The server has no proof the source address is real until the third message; accepting data in the SYN would let anyone with a spoofed address make the server do work. TFO allows it only for clients holding a cookie from a previous, verified connection.
▸Why keep two queues on the server?
Half-open and established connections have different costs and different attackers. Separating them lets the kernel shed half-open state under a SYN flood (cookies) without touching real connections, and lets the accept queue measure exactly one thing: how far behind the application is.
▸Why is the handshake such a big deal for performance?
Because it is paid in round trips, and round trips are the one cost that faster hardware does not reduce; at 150 ms RTT, no amount of bandwidth makes a fresh connection deliver its first byte sooner.
The three-way handshake
- server: socket() bind() listen()
- SYN seq=7482▶
- ◀SYN-ACK seq=20347 ack=7483
- ACK ack=20348▶
- PSH seq=7483 len=120 (HTTP request)▶
- ◀ACK ack=7603
How it fails
What the failure looks like from inside real software.
Recv-Qon the listening socket pinned at the backlog andListenOverflowsclimbing: the application is not calling accept() fast enough; clients see multi-second connect stalls while the server’s CPU is idle.- A middlebox stripping the window-scale option from SYNs: every connection through it is capped at 65,535 bytes in flight — a few Mbit/s on a long path — with no error anywhere.
- Instant
ECONNREFUSEDafter a deploy: the process is bound to127.0.0.1instead of0.0.0.0, or to the wrong port; the network was never involved. - A 127-second hang followed by
ETIMEDOUT: SYNs are being dropped — security group, firewall, black-holed route — and the client retransmitted six times before giving up. - SYN cookies engaged under legitimate load (a small backlog and a traffic spike): connections lose SACK and window scaling, and throughput per connection drops for the duration.
- A service doing one connection per request across an ocean: 300 ms per call of pure handshake, invisible in server-side latency graphs because the server never sees the wait.
Follow it through every layer
This lesson is one node of a longer journey. Zoom out, then zoom back in.