What is three-way handshake?
Three-way handshake is the TCP connection setup process where client and server exchange three packets (SYN, SYN-ACK, ACK) to confirm both sides are reachable and agree on sequence numbers before any data moves.
Theory
TL;DR
- Like a phone line check: "hello?" (SYN), "hello, I hear you" (SYN-ACK), "got it, let's talk" (ACK). Both sides confirm the channel works before saying anything real.
- Happens automatically for every TCP connection. HTTP, HTTPS, SSH, database connections, WebSockets - all start with this.
- The actual goal: both sides agree on initial sequence numbers so TCP can detect lost or out-of-order packets later.
- UDP skips the handshake entirely. Faster, but no delivery guarantee.
- ECONNREFUSED = server rejected your SYN (port closed). ETIMEDOUT = no SYN-ACK came back (server unreachable or filtered by firewall).
Quick example
const net = require('net');
const socket = net.createConnection({ host: 'example.com', port: 80 });
socket.on('connect', () => {
// Handshake is done - SYN -> SYN-ACK -> ACK happened in the OS kernel
socket.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n');
});
socket.on('error', (err) => {
// ECONNREFUSED: server is up, port is closed
// ETIMEDOUT: SYN went nowhere - wrong host, firewall, or dead server
console.log('Connection failed:', err.code);
});The connect event fires only after all three packets complete. Your application code never touches the handshake itself. The OS kernel handles everything.
How it works step by step
Step 1 (SYN): the client picks a random initial sequence number (ISN) and sends a SYN packet. "I want to connect, and my byte stream starts at this number."
Step 2 (SYN-ACK): the server picks its own ISN and replies. It acknowledges the client's ISN by echoing it plus one, and announces its own starting number. One packet, two jobs.
Step 3 (ACK): the client acknowledges the server's ISN. Both sides move to ESTABLISHED state. Data can flow.
That's why three steps are needed and not two. A two-step exchange would confirm the server got the client's SYN, but the client would never confirm it received the server's sequence number. The third step closes that gap.
// tcpdump output for a real handshake
// Client 192.168.1.100:54321 -> Server 192.168.1.1:80
// Step 1: SYN
192.168.1.100.54321 > 192.168.1.1.80: Flags [S], seq 1000000000
// Step 2: SYN-ACK
192.168.1.1.80 > 192.168.1.100.54321: Flags [S.], seq 2000000000, ack 1000000001
// Step 3: ACK
192.168.1.100.54321 > 192.168.1.1.80: Flags [.], ack 2000000001
// Data starts flowing
192.168.1.100.54321 > 192.168.1.1.80: Flags [P.] "GET / HTTP/1.1"Key difference from UDP
The handshake is not about sending data. It sets up shared context: both sides know where the other's byte stream starts. From that point, TCP can detect missing packets, duplicates, and reorder arrivals. UDP sends packets and forgets them. That trade-off is why video calls use UDP (a dropped frame is acceptable) and file transfers use TCP (every byte must arrive in order).
When to use
- TCP connections: the handshake is automatic. You don't choose it - it's part of the protocol.
- UDP (no handshake): DNS queries, video streaming, online games. Latency beats reliability there.
- Debugging timeouts: if a connection hangs, check whether SYN-ACK ever comes back. A firewall silently dropping SYN packets looks identical to an unreachable server from the client side.
- Security awareness: SYN flood attacks send millions of SYN packets without completing the ACK step. The server fills its connection table with half-open entries and runs out of resources.
Common mistakes
Mistake 1: writing data before the connection is ready
// Wrong: socket.write() before the handshake completes
const socket = net.createConnection({ host: 'example.com', port: 80 });
socket.write('GET / HTTP/1.1\r\n'); // handshake not done yet
// Right: wait for the connect event
socket.on('connect', () => {
socket.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n');
});Mistake 2: timeout too short for slow or distant networks
// Wrong: 100ms is not enough for cross-region connections
net.createConnection({ host: 'remote-server.com', port: 443, timeout: 100 });
// Right: handshake on a distant server can take 200-500ms
net.createConnection({ host: 'remote-server.com', port: 443, timeout: 5000 });Mistake 3: treating ECONNREFUSED and ETIMEDOUT as the same thing
ECONNREFUSED means the server replied with RST - it's alive, but that port is closed. ETIMEDOUT means your SYN got no reply at all: wrong host, firewall blocking it, or a dead server. Different errors need different fixes. Retrying on ECONNREFUSED is pointless.
Mistake 4: assuming sequence numbers are predictable
Old TCP stacks used time-based or sequential ISNs. Attackers could guess them and forge packets. Modern TCP randomizes ISNs per RFC 6528. Never build logic that depends on predicting a remote side's sequence number.
Mistake 5: running production servers without SYN flood protection
# Linux: enable SYN cookies at the OS level
sysctl net.ipv4.tcp_syncookies=1
# In nginx: rate-limit connections per IP
# limit_conn_zone $binary_remote_addr zone=addr:10m;
# limit_conn addr 100;SYN cookies let the server answer with SYN-ACK without allocating state for each half-open connection. If the client completes the handshake, state is reconstructed from the cookie. If it never does, nothing was wasted. I've seen this misconfigured on internal services before - a single load test from a staging environment looked like a SYN flood to the production box.
Real-world usage
- Browser requests: every HTTP GET starts with SYN. For HTTPS, a TLS handshake follows on top of the completed TCP handshake.
- SSH: that 1-2 second delay on
ssh user@hostis the TCP handshake plus key exchange, not network lag. - Database connections: MySQL, PostgreSQL, and MongoDB all go through a TCP handshake before the first query. Connection pooling exists to avoid repeating this on every request.
- WebSockets: the HTTP upgrade to WebSocket happens after TCP is already in ESTABLISHED state.
- Load balancers: nginx and HAProxy watch for handshake failures to mark backends as unavailable.
Follow-up questions
Q: Why three steps instead of two?
A: Two steps would confirm the server received the SYN, but the client would never confirm it got the server's sequence number. Without the third step, the server has no proof its packets reach the client at all.
Q: What happens if the final ACK is lost?
A: The server stays in SYN_RECV and retransmits SYN-ACK with exponential backoff, roughly 60 seconds total. The client is already in ESTABLISHED and sends data. The server sees data from an unexpected state, sends RST, and the client surfaces this as "Connection reset by peer."
Q: How does TIME_WAIT relate to the three-way handshake?
A: It doesn't directly - TIME_WAIT is about closing, not opening. After the four-way FIN exchange, the side that initiated close waits 2 minutes (2 x MSL) to ensure stale packets from that session don't corrupt a new connection reusing the same port. That's why you sometimes can't immediately restart a server on the same port.
Q: Can you send data inside the SYN packet?
A: Yes, with TCP Fast Open (TFO, RFC 7413). The server issues a cookie on the first connection. Future SYN packets carry that cookie plus data, cutting one round-trip from setup. Chrome and Linux kernel 3.7+ support TFO.
Q: [Senior] How do you detect and handle a SYN flood in production?
A: Watch netstat -an | grep SYN_RECV for growing half-open connections. Enable SYN cookies at the OS level. Put nginx or HAProxy in front with per-IP connection rate limiting. For critical services, use DDoS mitigation at the network edge (Cloudflare, AWS Shield). In Node.js, server.maxConnections caps accepted connections, but floods usually hit the kernel queue before your process ever sees them.
Examples
Basic: TCP server in Node.js
const net = require('net');
const server = net.createServer((socket) => {
// This callback fires only when the connection reaches ESTABLISHED state
// SYN / SYN-ACK / ACK already happened in the kernel
console.log('Client connected:', socket.remoteAddress);
socket.write('Connected\n');
socket.on('data', (data) => {
socket.write(`Echo: ${data}`);
});
socket.on('error', () => socket.destroy());
});
server.listen(3000, () => console.log('Listening on port 3000'));You never write code to handle individual handshake packets. The kernel does SYN / SYN-ACK / ACK, then hands you a ready socket.
Intermediate: diagnosing connection failures by error type
const net = require('net');
function connectWithDiagnosis(host, port) {
const socket = net.createConnection({ host, port, timeout: 5000 });
socket.on('connect', () => {
console.log('Handshake complete - connection ready');
});
socket.on('timeout', () => {
socket.destroy();
// No SYN-ACK received - check firewall rules or host reachability
console.log('Timeout: SYN-ACK never arrived');
});
socket.on('error', (err) => {
if (err.code === 'ECONNREFUSED') {
// Server is alive, SYN reached it, but the port is closed
console.log('Port closed - check the port number');
} else if (err.code === 'EHOSTUNREACH') {
// No route exists - check the IP address or network config
console.log('No route to host');
} else {
console.log('Unexpected error:', err.message);
}
});
}
connectWithDiagnosis('example.com', 80);ECONNREFUSED tells you the SYN reached the server. ETIMEDOUT tells you it didn't. These two facts point you in completely different directions when debugging.
Advanced: watching the handshake with tcpdump
# Capture TCP handshake packets in real time
tcpdump -i any 'tcp[tcpflags] & (tcp-syn|tcp-ack) != 0' -n
# What you'll see:
# 10:01:01 client.54321 > server.80: Flags [S], seq 1000000000
# 10:01:01 server.80 > client.54321: Flags [S.], seq 2000000000, ack 1000000001
# 10:01:01 client.54321 > server.80: Flags [.], ack 2000000001
# 10:01:01 client.54321 > server.80: Flags [P.] "GET / HTTP/1.1..."[S] is SYN. [S.] is SYN-ACK. [.] is a pure ACK. [P.] is data (PSH+ACK). Every TCP connection your machine makes produces this exact four-line sequence at the start.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.