The idea: three segments, two queues
Before a TCP connection can carry data, the two sides must agree on it. They do that with three segments: the client sends a SYN, the server answers with a SYN/ACK, and the client finishes with an ACK. This is the three-way handshake. Each side's socket moves through a few named states on the way. On the server, the kernel does the whole handshake by itself. It keeps the connections that are half done and the ones that are finished in two queues on the listening socket, and the program only sees a connection when it calls accept().
The handshake, state by state
The left side of the canvas is a time-down diagram, like the ones in TCP textbooks. Time goes down; each arrow is one segment and slopes down because it takes time to cross the network. The client's clock is written next to each arrow. In the model, one way takes 33 µs, so the SYN/ACK arrives 66 µs after the SYN (Demo: one handshake, then accept()).
| Segment | Sent by | Client state | Server state |
|---|---|---|---|
| (before) | CLOSED | LISTEN (the listening socket) | |
| SYN, seq = x | client, in connect() | SYN_SENT | SYN_RCVD when it arrives |
| SYN/ACK, seq = y, ack = x + 1 | server kernel | ESTABLISHED when it arrives; connect() returns | SYN_RCVD |
| ACK, ack = y + 1 | client kernel | ESTABLISHED | ESTABLISHED when it arrives |
The client is ESTABLISHED half a round trip before the server: it can already send data while its ACK is still on the way. Each SYN carries a random initial sequence number (x and y here), and each side acknowledges the other's number plus one.
The two queues of a listening socket
The right side of the canvas follows the classic figure from Stevens' UNIX Network Programming. For each listening socket the kernel keeps two queues:
- the incomplete connection queue: a SYN has arrived, the SYN/ACK has been sent, the server waits for the ACK. These entries are in SYN_RCVD.
- the completed connection queue: the handshake is finished. These entries are ESTABLISHED and wait for the program.
When the final ACK arrives, the entry moves from the incomplete queue to the tail of the completed queue. accept() takes the head of the completed queue and returns a new socket (fd 4, 5, …); the listening socket fd 3 stays in LISTEN. If the completed queue is empty, accept() blocks, even when the incomplete queue has entries (Demo: three clients fill the completed queue).
backlog: the sum of both queues
The second argument of listen(fd, backlog) limits the queues. In the classic BSD rule drawn here, the sum of both queues cannot exceed backlog. Linux counts differently: since 2.2, each queue is counted on its own. In current kernels both are capped by the same number, min(backlog, net.core.somaxconn): the accept queue holds up to that many finished connections (one more, in fact) and the SYN queue up to that many half-open ones, so together they can hold about twice backlog. When the SYN queue is full, Linux answers with SYN cookies (tcp_syncookies = 1) instead of dropping the SYN; tcp_max_syn_backlog only matters when cookies are off. The Linux Listen Backlog shows the Linux rules, somaxconn and SYN cookies.
A full queue ignores the SYN
When the queues are full and a SYN arrives, TCP ignores it: no SYN/ACK and no RST. The client's retransmission timer then resends the SYN, after 1 s, then 2 s more, 4 s more, and so on (Linux sends up to tcp_syn_retries = 6 retries). By then the program may have called accept() and made room. Sending an RST instead would make connect() fail at once with "connection refused", and the client could not tell a busy server from a closed port (Demo: queues full, SYN ignored, retry 1 s later). This is why a server that is slow to accept shows up as connects that take exactly 1 s or 3 s.
The usual cause in production is an overloaded web server. Its workers are all busy (a traffic spike, a slow database), so nobody calls accept(). The kernel keeps finishing handshakes, the completed queue (on Linux, the accept queue) fills up, and every new client waits 1 s, 3 s, 7 s … for a retry to find room. Clients that are already in the queue are connected but get no answer. When the server catches up, each accept() makes room for one waiting client (Demo: server overloaded, accept queue full). A bigger backlog only absorbs a short burst; the real fix is a faster or bigger server.
A lost ACK, and half-open entries
If the final ACK is lost, the client thinks it is connected, but the server's entry stays in SYN_RCVD in the incomplete queue, and accept() cannot return it. After 1 s the server resends its SYN/ACK; the client, already ESTABLISHED, sees it and sends the ACK again (Demo: lost ACK, stuck in SYN_RCVD). While it waits, the half-open entry still counts against backlog, so it can push a new client's SYN out (Demo: half-open entries count too). A SYN flood exploits exactly this: many SYNs from fake addresses that never answer fill the incomplete queue.
See also A TCP Socket in Linux for one connection from socket() to close(), How an HTTP Connection Is Established for the handshake inside a full request, and TCP vs UDP.
For HTTP/3, The QUIC Handshake vs TCP + TLS 1.3 shows how QUIC skips this handshake by carrying TLS in its first packet.
See also ARQ protocols for how sequence numbers and ACKs recover lost segments.
What the page leaves out
Real sequence numbers and TCP options (MSS, window scale, SACK, timestamps), data on the final ACK, simultaneous open, the server giving up on a half-open entry after its SYN/ACK retries, the Linux queue limits and SYN cookies (see The Linux Listen Backlog), TCP Fast Open, and closing the connection (FIN, TIME_WAIT).