How a TCP connection is established — from the first SYN to the moment data can flow. With code examples and sequence numbers explained.
What Is the Three-Way Handshake?
Before any data can be sent over a TCP connection, the two endpoints must agree to talk to each other and exchange some initial setup information. This setup process is called the three-way handshake because exactly three segments are exchanged between the client and the server. After these three steps, the connection is ESTABLISHED and data can flow.
The three steps are: SYN → SYN-ACK → ACK. Each step has a purpose. This tutorial explains each one clearly.
Why Exactly Three Steps?
You might wonder: why not just one step (client says “connect”, server says “ok”)? The reason is that TCP is a bidirectional, reliable protocol. Setting up a bidirectional connection requires both sides to:
- Tell the other side their initial sequence number (ISN) — the starting counter for bytes they will send
- Confirm they received the other side’s initial sequence number (by sending an ACK)
Since two sequence numbers need to be acknowledged (one in each direction), you need at least three segments. Two segments would only cover one direction. Four would be wasteful because the SYN-ACK covers two things at once.
Understanding Initial Sequence Numbers (ISN)
TCP numbers every byte of data it sends, starting from an Initial Sequence Number (ISN). The ISN is chosen randomly at connection time (not starting from 0) for security reasons — this makes it harder for attackers to inject data into an existing connection.
Let’s say:
- Client chooses ISN = M (some random number like 1234567)
- Server chooses ISN = N (another random number like 9876543)
The SYN flag itself consumes one sequence number. So after the client sends SYN M, the server must acknowledge with ACK M+1 (meaning “I got byte M, now send me byte M+1”). Similarly, after the server sends SYN N, the client must acknowledge with ACK N+1.
Three-Way Handshake Step by Step
The client application calls connect(). The operating system sends a SYN segment to the server. This segment contains:
- The SYN flag set to 1
- The client’s Initial Sequence Number (M) — chosen randomly by the OS
- No data payload
The client socket moves to SYN_SENT state. The connect() call blocks, waiting for the server’s reply.
The server receives the SYN. It does two things in a single segment (this is called piggybacking):
- Acknowledges the client’s SYN: Sets the ACK flag and puts M+1 in the acknowledgement field. This tells the client “I received your SYN (sequence M), now send byte M+1”.
- Sends its own SYN: Sets the SYN flag and puts N (its own ISN) in the sequence field. This tells the client “my bytes will start at N, please acknowledge N+1”.
The server socket moves from LISTEN to SYN_RECV state.
The client receives the SYN-ACK. It:
- Sends a final ACK segment with Ack=N+1, acknowledging the server’s SYN
- Moves to ESTABLISHED state
- The
connect()call returns successfully
When the server receives this ACK, it also moves to ESTABLISHED state. The three-way handshake is complete. Data can now flow in both directions.
Complete Handshake Timeline
API-Level View — What Your Code Does
At the application level, you do not deal with SYN segments directly. The OS handles all of that. Here is what your C code looks like on both sides:
Server side (passive open):
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main() {
/* Step 1: Create socket */
int sfd = socket(AF_INET, SOCK_STREAM, 0);
/* Step 2: Bind to a port */
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sfd, (struct sockaddr *)&addr, sizeof(addr));
/* Step 3: Mark socket as passive open → moves to LISTEN state */
listen(sfd, 10);
printf("Waiting for connections...\n");
/* Step 4: Block until client connects.
Internally: OS waits for SYN, sends SYN-ACK, waits for ACK.
When three-way handshake completes, accept() returns
a NEW socket fd for that specific connection. */
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int cfd = accept(sfd, (struct sockaddr *)&client_addr, &len);
/* cfd is now in ESTABLISHED state */
printf("Client connected!\n");
/* Now we can read/write */
char buf[256];
int n = read(cfd, buf, sizeof(buf) - 1);
buf[n] = '\0';
printf("Received: %s\n", buf);
write(cfd, "Hello from server!\n", 19);
close(cfd);
close(sfd);
return 0;
}
Client side (active open):
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main() {
/* Step 1: Create socket */
int cfd = socket(AF_INET, SOCK_STREAM, 0);
/* Step 2: Specify server address */
struct sockaddr_in srv;
memset(&srv, 0, sizeof(srv));
srv.sin_family = AF_INET;
srv.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &srv.sin_addr);
/*
* Step 3: connect() triggers the three-way handshake:
* OS sends SYN (seq = random ISN M)
* Socket moves to SYN_SENT
* OS waits for SYN-ACK from server
* OS sends ACK
* connect() returns when socket is ESTABLISHED
*/
int ret = connect(cfd, (struct sockaddr *)&srv, sizeof(srv));
if (ret == 0) {
printf("Connected to server! Handshake complete.\n");
}
/* Now send and receive data */
write(cfd, "Hello from client!\n", 19);
char buf[256];
int n = read(cfd, buf, sizeof(buf) - 1);
buf[n] = '\0';
printf("Server replied: %s\n", buf);
close(cfd);
return 0;
}
Observing the Handshake with tcpdump
You can see the three-way handshake happen in real time using tcpdump:
/* Terminal 1: Capture TCP on port 8080 */
$ sudo tcpdump -i lo port 8080 -n
/* Terminal 2: Run the server, then the client */
/* tcpdump output will show something like: */
12:01:45.123456 IP 127.0.0.1.54321 > 127.0.0.1.8080: Flags [S], seq 1234567, length 0
^^^^ SYN from client (step 1)
12:01:45.123600 IP 127.0.0.1.8080 > 127.0.0.1.54321: Flags [S.], seq 9876543, ack 1234568, length 0
^^^^^^^^^^^ SYN-ACK from server (step 2, note ack = M+1)
12:01:45.123700 IP 127.0.0.1.54321 > 127.0.0.1.8080: Flags [.], ack 9876544, length 0
^^^^^ ACK from client (step 3, note ack = N+1)
/* Flags key:
[S] = SYN
[S.] = SYN + ACK
[.] = ACK only
[F] = FIN
[P] = PSH (data being sent)
[R] = RST (reset / abort)
*/
What Can Go Wrong During the Handshake?
Interview Questions
Answer: It is three-way because exactly three segments are exchanged: SYN, SYN-ACK, and ACK. It cannot be two-way because each side must both advertise its own ISN and acknowledge the other side’s ISN — that requires at least three steps. It is not four-way because the server’s acknowledgement of the client’s SYN is piggybacked onto the server’s own SYN in the SYN-ACK segment, saving one round trip.
Answer: TCP sequence numbers are chosen randomly at connection time for security reasons. If they always started at 0, an attacker who knows the IP:port pair of a connection could predict the sequence numbers and inject forged segments into the connection. Random ISNs make this attack much harder. Modern Linux uses a cryptographically randomized ISN generation algorithm.
Answer: Piggybacking refers to combining multiple pieces of information into a single segment. In the three-way handshake, the server’s response (step 2) is a SYN-ACK — it both acknowledges the client’s SYN (ACK) and sends the server’s own SYN in the same segment. This saves one network round trip, reducing connection setup latency.
Answer: TCP sequence numbers track bytes of data. Normally only data bytes consume sequence numbers. But the SYN flag is an exception — it consumes one sequence number even though no data is being sent. This is necessary so that the SYN can be acknowledged unambiguously. If SYN M is sent, the acknowledgement is M+1, making it clear that the SYN itself (occupying the virtual byte M) was received. The FIN flag also consumes one sequence number for the same reason.
Answer: A SYN flood attack is a denial-of-service attack where an attacker sends many SYN segments with spoofed source IP addresses but never sends the final ACK. The server creates SYN_RECV entries in its SYN queue for each one. Eventually the queue fills up and legitimate connections are rejected. Linux defends against this using SYN cookies (enabled via net.ipv4.tcp_syncookies=1). Instead of allocating queue space, the server encodes the connection parameters into the sequence number of the SYN-ACK. When a legitimate final ACK arrives, the server can reconstruct the connection state from the ACK number, without having kept any state during the handshake.
