“Explain the TCP three-way handshake” sounds like a question with a memorised answer: SYN, SYN-ACK, ACK. Interviewers ask it anyway because the follow-ups separate people who drew the diagram once from people who have debugged a connection. Why three messages and not two? Who actually sends the SYN-ACK? What does it mean when connect() succeeds but the request still hangs? Why does a client machine sometimes run out of ports?
This note answers those with real output from a laptop: a deliberately slow Node server on localhost, curl’s timing variables, and netstat.
What the three segments agree on
TCP gives each direction of a connection its own numbered byte stream. Every byte has a sequence number, and acknowledgements say “I have everything up to byte N”. Before any data flows, each side has to pick a starting number (the initial sequence number, or ISN) and know that the other side has learned it.
- SYN: the client says “my stream starts at x”. It also carries options that can only be negotiated here: maximum segment size, window scaling, whether selective acknowledgements are allowed, and timestamps.
- SYN-ACK: the server acknowledges x+1 and says “my stream starts at y”, with its own options.
- ACK: the client acknowledges y+1. The client may put its first data in this same segment.
Two messages aren’t enough because the server would never learn whether its own starting number arrived. There’s a second reason the RFC spells out: old duplicate SYNs. A SYN delayed in the network from an earlier attempt could otherwise open a connection nobody asked for. With the third step, the client can see that the SYN-ACK acknowledges a number it never sent and reset it.
ISNs are randomised, not counted up from zero. Predictable sequence numbers once let attackers inject packets into other people’s connections, which is why “the ISN is a security feature as well as a bookkeeping one” is a good line to have ready.
The kernel answers, not your application
Here is the part that trips up a lot of candidates. When a server process calls listen(), the operating system starts completing handshakes on its behalf. Finished connections wait in an accept queue until the application calls accept(). Your code isn’t involved in the SYN-ACK at all.
You can see this with a Node server that blocks its own event loop for three seconds on every request:
import http from 'node:http';
const server = http.createServer((req, res) => {
const start = Date.now();
while (Date.now() - start < 3000) {} // block the event loop for 3 s
res.end('done\n');
});
server.listen(8081, '127.0.0.1');Two requests, the second started 0.2 seconds after the first:
curl -s -o /dev/null -w 'A connect=%{time_connect}s first_byte=%{time_starttransfer}s\n' http://127.0.0.1:8081/ &
sleep 0.2
curl -s -o /dev/null -w 'B connect=%{time_connect}s first_byte=%{time_starttransfer}s\n' http://127.0.0.1:8081/A connect=0.000515s first_byte=3.001642s
B connect=0.000267s first_byte=5.802538sRequest B connected in a quarter of a millisecond while the server’s only thread was stuck in a loop. The handshake had nothing to do with the application. B then waited almost six seconds for its first byte: the rest of A’s loop, then its own.
This is the misconception interviewers are fishing for. A load balancer health check that only opens a TCP connection proves the kernel is up and something holds the port. It doesn’t prove the process can serve a request. The accept queue is also finite (on this Mac, kern.ipc.somaxconn is 128), so a server that stops calling accept() eventually stops taking new connections cleanly, and what clients see then depends on the operating system.
Refused versus timed out
When a connection fails, the error tells you how far the SYN got.
curl -v http://127.0.0.1:8099/* connect to 127.0.0.1 port 8099 from 127.0.0.1 port 55299 failed: Connection refused
* Failed to connect to 127.0.0.1 port 8099 after 0 ms: Couldn't connect to serverNode reports the same situation as ECONNREFUSED connect ECONNREFUSED 127.0.0.1:8099. Refused means the SYN reached a host and that host replied with a reset (RST): nothing is listening on that address and port. It arrived in 0 ms, so you know the route works. The usual causes are a stopped process, the wrong port, or a server bound to 127.0.0.1 while you connect to ::1 (or the reverse).
A timeout looks different. 192.0.2.1 is a documentation address that is never routed, so nothing replies:
curl -sS --connect-timeout 2 http://192.0.2.1/curl: (28) Failed to connect to 192.0.2.1 port 80 after 2006 ms: Timeout was reachedSilence means the SYN or the SYN-ACK was lost: a firewall dropping packets, a missing route, a security group that doesn’t allow the port, or a host that’s down. The kernel retransmits the SYN with growing gaps; on Linux the default of six retries gives up after roughly two minutes. That’s why every client should set its own connect timeout. Waiting two minutes to learn a host is unreachable is rarely what you want.
Interview tip
In an incident, “refused” and “timed out” point at different teams. Refused usually means the service or its port. Timed out usually means the network path or a firewall rule.
What the handshake costs
The handshake takes one round trip before the client can send anything. Over HTTPS, TLS adds at least one more. curl can show each phase against a real site (times are cumulative from the start):
curl -s -o /dev/null -w 'dns=%{time_namelookup} tcp_done=%{time_connect} tls_done=%{time_appconnect} first_byte=%{time_starttransfer}\n' https://example.com/dns=0.002105 tcp_done=0.007623 tls_done=0.018575 first_byte=0.034380ping put the round trip to this server at about 5.7 ms, and the TCP phase took about 5.5 ms: one round trip, as expected. TLS 1.3 took another 11 ms (a round trip plus certificate checks). The server is close by. Put it 80 ms away and the same sequence costs a quarter of a second before the first byte of the request leaves your machine. That arithmetic is the whole case for connection reuse and keep-alive: you pay the handshake once and spread it over many requests.
Closing the connection and TIME_WAIT
Closing is a separate exchange. Each side sends a FIN for its own direction and the other acknowledges it, so a connection can be half-closed: one side finished sending while the other keeps going.
The side that closes first enters TIME_WAIT and holds the address-and-port pair for twice the maximum segment lifetime. That stops a stray, delayed segment from the old connection being accepted by a new one that reuses the same ports. After the curl runs above, netstat showed:
tcp4 0 0 127.0.0.1.8081 *.* LISTEN
tcp4 0 0 127.0.0.1.55296 127.0.0.1.8081 TIME_WAIT
tcp4 0 0 127.0.0.1.55298 127.0.0.1.8081 TIME_WAITcurl closed first, so the client side holds TIME_WAIT. On macOS the segment lifetime is 15 seconds (net.inet.tcp.msl: 15000), so these entries last 30 seconds; Linux uses a fixed 60. The ephemeral port range here is 49152 to 65535, about 16,000 ports. A client that opens and closes thousands of short connections per second to the same destination can run through them, and new connects start failing with “address not available”. The fix is almost never kernel tuning. It’s reusing connections.
Interview practice
Your load balancer marks every instance healthy using TCP checks on port 8080. During a traffic spike, users see requests hang for 30 seconds and then fail, yet no instance is ever removed. What’s going on, and what would you change?
Answer
A TCP check passes as soon as the kernel completes the handshake, and the kernel does that for any process holding the port, even one that is overloaded or deadlocked. During the spike the application isn’t draining requests fast enough (a blocked event loop, an exhausted thread pool or a database pool that’s run dry), so connections are accepted and then sit. The load balancer keeps sending traffic to instances that can’t serve it.
I’d switch to an HTTP health check against an endpoint that runs through the real request path, with a short timeout, so a slow instance fails its check. I’d keep that endpoint cheap and make sure it doesn’t depend on every downstream service, or one slow dependency would mark the whole fleet unhealthy at once. Then I’d look at time to first byte separately from connect time on the client side. A tiny connect time with a long wait for the first byte, like request B above, points at the application, not the network.