Why You Need to Understand TCP Internals
Most socket programming bugs aren’t coding errors — they’re misunderstandings of how TCP works. Why does my server fail to bind after a crash? Why does my application feel sluggish sending small messages? Why does my client hang in CLOSE_WAIT?
This tutorial walks through the TCP connection lifecycle, explains the states you’ll see in netstat, and explains the Nagle algorithm and when to disable it.
TCP Connection Establishment: The 3-Way Handshake
Every TCP connection begins with a 3-way handshake. This synchronises sequence numbers between the two endpoints.
From a socket API perspective, the handshake happens transparently:
Client: socket() → connect() [triggers SYN, blocks until ESTABLISHED]
TCP State Machine
TCP connections move through a series of states. Understanding these is essential for reading netstat output and debugging.
TIME_WAIT: The Most Misunderstood State
TIME_WAIT is entered by the side that initiates the close (sends the first FIN). It persists for 2 × MSL (Maximum Segment Lifetime) — typically 60 seconds on Linux (2 × 30s).
During TIME_WAIT, the port cannot be reused for a new connection to the same destination. This causes a common problem: you restart a server and get “Address already in use”.
The active closer sends the final ACK for the peer’s FIN. If this ACK is lost, the peer will retransmit its FIN. TIME_WAIT keeps the socket alive long enough to receive and re-ACK that retransmitted FIN. Without TIME_WAIT, the retransmitted FIN would be RST’d, breaking the protocol.
Delayed or duplicate packets from the old connection must not be accepted by a new connection on the same {src IP, src port, dst IP, dst port} 4-tuple. TIME_WAIT ensures the old connection’s packets have expired (lived their MSL) before the 4-tuple can be reused.
Solving “Address Already In Use” with SO_REUSEADDR
The fix is to set SO_REUSEADDR before calling bind(). This allows binding to a port that has sockets in TIME_WAIT state from a previous incarnation of the server.
#include <sys/socket.h>
int setup_server_socket(int port)
{
int sockfd;
struct sockaddr_in addr;
int opt = 1;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) { perror("socket"); return -1; }
/*
* SO_REUSEADDR: allow bind() even if the port has
* sockets in TIME_WAIT from a previous server run.
* ALWAYS set this on server sockets.
*/
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
perror("setsockopt SO_REUSEADDR");
close(sockfd);
return -1;
}
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
perror("bind");
close(sockfd);
return -1;
}
return sockfd;
}
The CLOSE_WAIT Problem
If you see many sockets in CLOSE_WAIT in netstat, your application has a bug — it’s not calling close() on connections after the peer has closed their end.
/* BUGGY code — causes CLOSE_WAIT accumulation */
while ((n = read(connfd, buf, sizeof(buf))) > 0) {
process(buf, n);
}
/* n == 0 means EOF (peer closed) — but we forget to close! */
/* FIXED */
while ((n = read(connfd, buf, sizeof(buf))) > 0) {
process(buf, n);
}
if (n == 0) {
printf("Peer closed — closing our side\n");
close(connfd); /* This completes the 4-way close, exiting CLOSE_WAIT */
} else if (n == -1) {
perror("read");
close(connfd);
}
The Nagle Algorithm
TCP was designed in an era of slow, congested networks. John Nagle invented an algorithm (RFC 896) to prevent a flood of tiny packets — the so-called “small packet problem” or “tinygram problem”.
When to disable Nagle (set TCP_NODELAY): Real-time interactive applications — SSH key press echoing, multiplayer games, telnet, financial trading systems. Any application where latency matters more than bandwidth efficiency.
#include <netinet/tcp.h>
/* Disable Nagle algorithm on a socket */
int disable_nagle(int sockfd)
{
int flag = 1; /* 1 = disable Nagle (enable TCP_NODELAY) */
if (setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY,
&flag, sizeof(flag)) == -1) {
perror("setsockopt TCP_NODELAY");
return -1;
}
printf("Nagle algorithm disabled — small writes sent immediately\n");
return 0;
}
/* Re-enable Nagle (usually not needed — it's on by default) */
int enable_nagle(int sockfd)
{
int flag = 0;
return setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY,
&flag, sizeof(flag));
}
TCP Connection Timeout and Retransmission
When connect() is called and no SYN+ACK arrives, TCP retransmits the SYN with exponential backoff. On Linux, the default timeout is around 127 seconds (after roughly 6 retransmission attempts). This can make your application hang for over 2 minutes on a dead host.
#include <sys/socket.h>
#include <fcntl.h>
#include <sys/select.h>
#include <errno.h>
/*
* Non-blocking connect with a custom timeout.
* Returns 0 on success, -1 on error or timeout.
*/
int connect_with_timeout(int sockfd, struct sockaddr *addr,
socklen_t addrlen, int timeout_sec)
{
int flags, result;
fd_set wfds;
struct timeval tv;
/* Set socket to non-blocking */
flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
result = connect(sockfd, addr, addrlen);
if (result == 0) {
/* Connected immediately (e.g., loopback) */
fcntl(sockfd, F_SETFL, flags); /* restore blocking */
return 0;
}
if (errno != EINPROGRESS) {
perror("connect"); return -1;
}
/* Wait for the socket to become writable (connection complete) */
FD_ZERO(&wfds);
FD_SET(sockfd, &wfds);
tv.tv_sec = timeout_sec;
tv.tv_usec = 0;
result = select(sockfd + 1, NULL, &wfds, NULL, &tv);
if (result == 0) {
fprintf(stderr, "connect timed out after %d seconds\n", timeout_sec);
return -1;
}
if (result == -1) {
perror("select"); return -1;
}
/* Check if connection actually succeeded */
int soerr;
socklen_t solen = sizeof(soerr);
getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &soerr, &solen);
if (soerr != 0) {
fprintf(stderr, "connect failed: %s\n", strerror(soerr));
return -1;
}
fcntl(sockfd, F_SETFL, flags); /* restore blocking mode */
printf("Connected successfully\n");
return 0;
}
