Skip to content
Menu

Networking7 min read

TCP State Machine

Connection lifecycle from LISTEN to CLOSED

TCP Connection States

Every TCP connection traverses a well-defined state machine. Understanding these states is essential for diagnosing connection issues — when you run ss or netstat, you're looking at these states.

Server-Side Path (Passive Open)

CLOSED
→ listen()
LISTEN
→ recv SYN
SYN_RCVD
→ recv ACK
ESTABLISHED

Client-Side Path (Active Open)

CLOSED
→ send SYN
SYN_SENT
→ recv SYN+ACK
ESTABLISHED

Active Close (Initiator of Close)

ESTABLISHED
→ send FIN
FIN_WAIT_1
→ recv ACK
FIN_WAIT_2
→ recv FIN
TIME_WAIT
→ 2xMSL timeout
CLOSED

Passive Close (Peer of Close Initiator)

ESTABLISHED
→ recv FIN
CLOSE_WAIT
→ send FIN
LAST_ACK
→ recv ACK
CLOSED

All 11 TCP States

StateSideDescription
CLOSEDBothNo connection exists (fictional starting/ending state)
LISTENServerWaiting for incoming SYN (passive open)
SYN_SENTClientSYN sent, waiting for SYN-ACK
SYN_RCVDServerSYN received, SYN-ACK sent, waiting for final ACK
ESTABLISHEDBothConnection open, data flows bidirectionally
FIN_WAIT_1Active closerFIN sent, waiting for ACK of FIN
FIN_WAIT_2Active closerFIN ACKed, waiting for peer's FIN
TIME_WAITActive closerBoth FINs exchanged, waiting 2xMSL before fully closing
CLOSE_WAITPassive closerReceived peer's FIN, waiting for application to call close()
LAST_ACKPassive closerFIN sent, waiting for final ACK
CLOSINGBothSimultaneous close — both sides sent FIN before receiving peer's FIN (rare)

4-Way Connection Teardown

TCP close is a 4-segment exchange (can be collapsed to 3 if FIN+ACK are combined). Each direction is shut down independently — TCP is full-duplex, so each side must send its own FIN.

Active Closer (Client) Passive Closer (Server)
FIN   seq=5000
ACK   ack=5001
FIN   seq=8000
ACK   ack=8001

Teardown Sequence

  1. FIN (client): Client calls close(). Kernel sends FIN. Client enters FIN_WAIT_1.
  2. ACK (server): Server's kernel ACKs the FIN. Server enters CLOSE_WAIT. Client enters FIN_WAIT_2.
  3. FIN (server): Server application calls close(). Kernel sends FIN. Server enters LAST_ACK.
  4. ACK (client): Client ACKs the FIN. Client enters TIME_WAIT. Server enters CLOSED.

Note

In practice: Many implementations combine steps 2 and 3 into a single FIN+ACK segment, making it a 3-segment teardown. This happens when the server application calls close() quickly after receiving the client's FIN.

TIME_WAIT

The most misunderstood TCP state. After the active closer sends its final ACK, it enters TIME_WAIT for 2 x MSL (Maximum Segment Lifetime). On Linux, MSL = 30s, so TIME_WAIT lasts 60 seconds.

Why TIME_WAIT Exists

  1. Reliable termination: If the final ACK is lost, the peer will retransmit its FIN. The TIME_WAIT state ensures the closing side can re-ACK it.
  2. Prevent stale segments: Delayed segments from the old connection might arrive after a new connection is established on the same 5-tuple. TIME_WAIT ensures all old segments have expired (TTL exceeded) before the 5-tuple can be reused.

TIME_WAIT Accumulation

On busy servers that initiate many short-lived connections (e.g., a reverse proxy connecting to backends), TIME_WAIT sockets can accumulate in the tens of thousands.

console
console

    # Count connections by state

    $ ss -s

    Total: 15423

    TCP:   12847 (estab 3201, closed 1024, orphaned 12, timewait 8610)


    # Count TIME_WAIT connections per destination

    $ ss -tan state time-wait | awk '{print $4}' | sort | uniq -c | sort -rn | head

      3412  10.0.1.5:8080

      2891  10.0.1.6:8080

      1204  10.0.2.3:443

      1103  10.0.2.4:443
  

Mitigations

ApproachHowTrade-offs
SO_REUSEADDR Socket option — allows bind() to a local address in TIME_WAIT Safe, universally used for server sockets
tcp_tw_reuse sysctl net.ipv4.tcp_tw_reuse=1 Allows reusing TIME_WAIT sockets for outgoing connections (client-side only). Safe with timestamps enabled.
Connection pooling Keep-alive / persistent connections, HTTP/2 multiplexing Best solution — avoid the problem entirely. Fewer connections = fewer TIME_WAITs.
Let the peer close first Architecture change — make the server the passive closer TIME_WAIT lands on the side with more ephemeral ports (the client).

Warning

Never use tcp_tw_recycle: This sysctl was removed from Linux kernel 4.12 because it breaks connections from clients behind NAT (which share source IPs but have different timestamp clocks). If you see it in old tuning guides, ignore it.

CLOSE_WAIT Accumulation

While TIME_WAIT is normal, CLOSE_WAIT accumulation is always a bug. It means the remote peer has sent a FIN (closed its side), but your application has not called close() on the socket.

Common Causes

  • Resource leak: Application reads EOF from the socket but never closes it (missing close() / defer conn.Close())
  • Blocked thread: Application is stuck processing and never returns to its read loop to notice the FIN
  • Connection pool bug: Connection returned to pool after peer closed it, pool doesn't validate liveness
console
console

    # Find CLOSE_WAIT sockets and the owning process

    $ ss -tanp state close-wait

    Recv-Q  Send-Q  Local Address:Port  Peer Address:Port  Process

    0       0       10.0.0.1:44832      10.0.0.5:443       users:(("myapp",pid=12345,fd=47))

    0       0       10.0.0.1:44833      10.0.0.5:443       users:(("myapp",pid=12345,fd=48))

    0       0       10.0.0.1:44834      10.0.0.5:443       users:(("myapp",pid=12345,fd=49))


    # Count open file descriptors for the suspect process

    $ ls -la /proc/12345/fd | wc -l

    4096
  

Tip

Diagnosis workflow: If you see CLOSE_WAIT growing over time, identify the PID with ss -tanp state close-wait, then examine the application code for socket leaks. Check /proc/PID/fd count — if it keeps growing, you have a file descriptor leak.

Half-Open Connections

A half-open connection occurs when one side believes the connection is ESTABLISHED but the other side has no knowledge of it (crashed, rebooted, or network partition).

How Half-Open Connections Happen

  • Peer crashes without sending FIN: Process kill -9, kernel panic, power failure — no graceful close
  • Network partition: Firewall rule change, route withdrawal, cable unplugged
  • NAT timeout: Stateful NAT/firewall drops the mapping after idle timeout, but both endpoints still think connection is alive

Detection Mechanisms

MechanismHow It WorksConfiguration
TCP keepalive After idle period, send probe segments. If no response after retries, declare connection dead. tcp_keepalive_time=7200 (2h default!), tcp_keepalive_intvl=75, tcp_keepalive_probes=9
Application-level heartbeat Application protocol sends periodic pings (gRPC keepalive, WebSocket ping/pong) Much faster detection — typically 10-30 seconds
RST on data send If the surviving side sends data, the rebooted peer responds with RST (it has no record of the connection) Only works when the surviving side actively sends data

Warning

The 2-hour trap: Default TCP keepalive is 7200 seconds (2 hours) before the first probe. In K8s environments with pod restarts, service mesh proxies, and cloud load balancers, this is far too long. Always configure application-level keepalives or tune the kernel settings.

console
console

    # Tune TCP keepalive for K8s/cloud workloads

    $ sysctl -w net.ipv4.tcp_keepalive_time=60

    $ sysctl -w net.ipv4.tcp_keepalive_intvl=10

    $ sysctl -w net.ipv4.tcp_keepalive_probes=6

    # Total detection time: 60 + (10 * 6) = 120 seconds
  

SYN Floods & SYN Cookies

A SYN flood is a classic DoS attack that exploits the TCP handshake. The attacker sends a barrage of SYN segments with spoofed source IPs. The server allocates resources for each half-open connection in the SYN queue and sends SYN-ACKs — but the ACKs never come.

Attacker (spoofed IPs) Server
SYN (src=1.2.3.4)
SYN (src=5.6.7.8)
SYN (src=9.10.11.12) ...

SYN Cookies Defense

SYN cookies (RFC 4987) eliminate the need to store state for half-open connections. Instead of allocating a SYN queue entry, the server encodes the connection state into the ISN itself:

  • The server's ISN is a cryptographic hash of (source IP, source port, dest IP, dest port, timestamp, secret key)
  • No state is stored — if the ACK comes back, the server reconstructs the connection info from the ack number
  • Trade-off: TCP options from the SYN (MSS, window scale, SACK) are lost (partially mitigated by encoding MSS in the hash)
console
console

    # Check if SYN cookies are enabled (1 = enabled when SYN queue overflows)

    $ sysctl net.ipv4.tcp_syncookies

    net.ipv4.tcp_syncookies = 1


    # Check SYN queue overflow counter

    $ netstat -s | grep "SYNs to LISTEN"

        12847 SYNs to LISTEN sockets dropped


    # Increase SYN backlog

    $ sysctl -w net.ipv4.tcp_max_syn_backlog=4096
  

Tip

Modern Linux: SYN cookies are enabled by default and activate automatically when the SYN queue overflows. Under normal operation, the kernel uses the standard SYN queue. Under attack, it seamlessly falls back to SYN cookies. You generally don't need to touch this.

Inspecting TCP States with ss

ss (socket statistics) is the modern replacement for netstat. It's faster (reads directly from kernel netlink) and more feature-rich.

console
console

    # All TCP connections with state

    $ ss -tan

    State      Recv-Q  Send-Q  Local Address:Port  Peer Address:Port

    LISTEN     0       4096    0.0.0.0:443         0.0.0.0:*

    LISTEN     0       4096    0.0.0.0:80          0.0.0.0:*

    ESTAB      0       0       10.0.0.1:443        10.0.0.5:52341

    TIME-WAIT  0       0       10.0.0.1:443        10.0.0.6:48912

    CLOSE-WAIT 0       0       10.0.0.1:44832      10.0.1.5:8080


    # Filter by state

    $ ss -tan state established

    $ ss -tan state time-wait

    $ ss -tan state close-wait


    # Show process info (requires root)

    $ ss -tanp state listening


    # Show extended TCP info (cwnd, rtt, etc.)

    $ ss -ti dst 10.0.0.5


    # Summary of all socket states

    $ ss -s
  

ss Key Flags

FlagMeaning
-tTCP sockets only
-uUDP sockets only
-aAll states (including LISTEN and TIME-WAIT)
-nNumeric output (no DNS resolution, faster)
-lListening sockets only
-pShow process using the socket
-iShow TCP internal info (cwnd, rtt, mss, etc.)
-sSummary statistics

Note

Recv-Q / Send-Q meaning changes by state: For LISTEN sockets, Recv-Q = number of pending connections in the accept queue, Send-Q = backlog size. For ESTABLISHED sockets, Recv-Q = bytes in receive buffer not yet read by app, Send-Q = bytes sent but not yet ACKed.

Solidnines — solidnines.com