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().

Client states CLOSED, SYN_SENT, ESTABLISHED and server states LISTEN, SYN_RCVD, ESTABLISHED, with the segment that causes each change: connect() sends SYN, receiving SYN/ACK sends ACK; receiving SYN sends SYN/ACK, receiving ACK finishes.
Each state change is caused by sending or receiving one of the three segments.

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()).

SegmentSent byClient stateServer state
(before)CLOSEDLISTEN (the listening socket)
SYN, seq = xclient, in connect()SYN_SENTSYN_RCVD when it arrives
SYN/ACK, seq = y, ack = x + 1server kernelESTABLISHED when it arrives; connect() returnsSYN_RCVD
ACK, ack = y + 1client kernelESTABLISHEDESTABLISHED 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.

Left, BSD: one dashed box around both queues, labelled incomplete + completed ≤ backlog. Right, Linux: the accept queue and the SYN queue are separate boxes, each limited by min(backlog, somaxconn) and counted on its own; a full SYN queue answers with SYN cookies.
BSD counts both queues together against backlog; Linux gives each queue the same limit, min(backlog, somaxconn), and counts them separately.

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).