Mental model
A TCP connection isn’t a binary “open or closed” thing — it’s a state machine with 11 distinct states. The OS kernel tracks the current state of every socket and moves it through the lifecycle based on packets sent/received.
You don’t need to memorize all 11. Five matter day-to-day:
| State | What’s happening |
|---|---|
| LISTEN | Server is accepting connections on a port |
| SYN_SENT | Client has sent SYN, waiting for SYN-ACK |
| SYN_RECEIVED | Server got the SYN, sent SYN-ACK, waiting for client’s ACK |
| ESTABLISHED | Connection is open, data flowing |
| TIME_WAIT | Connection closed; waiting before fully releasing the port |
The full set (FIN_WAIT_1, FIN_WAIT_2, CLOSE_WAIT, LAST_ACK, CLOSING, CLOSED) handles the orderly teardown. Worth knowing they exist; not worth memorizing every transition for CCNA.
The states, in order of a typical connection
Server: CLOSED → LISTEN (listens on port 443)
Client: CLOSED → SYN_SENT (sends SYN)
Server: LISTEN → SYN_RECEIVED (responds SYN-ACK)
Client: SYN_SENT → ESTABLISHED (sends ACK, connection open)
Server: SYN_RECEIVED → ESTABLISHED (gets ACK, connection open)
... data flows ...
Client: ESTABLISHED → FIN_WAIT_1 (sends FIN)
Server: ESTABLISHED → CLOSE_WAIT (gets FIN, sends ACK)
Client: FIN_WAIT_1 → FIN_WAIT_2 (got server's ACK)
Server: CLOSE_WAIT → LAST_ACK (app calls close, sends FIN)
Client: FIN_WAIT_2 → TIME_WAIT (got server's FIN, sends ACK)
Server: LAST_ACK → CLOSED (got client's ACK)
Client: TIME_WAIT → CLOSED (after 2 × MSL timer expires)
TIME_WAIT — the one that confuses everyone
After a graceful close, the side that sent the first FIN enters TIME_WAIT and stays there for 2 × MSL (Maximum Segment Lifetime, typically 30-60 seconds on modern systems, so 60-120s total).
Why? Two reasons:
-
Catch stragglers. If the final ACK is lost, the other side retransmits its FIN. The TIME_WAIT side responds with another ACK. Without TIME_WAIT, the second FIN reaches a “closed” socket and gets RST.
-
Avoid stale connection confusion. Old packets from this connection might still be in flight. TIME_WAIT ensures they’re discarded before the same (src IP, src port, dst IP, dst port) 4-tuple can be reused for a new connection.
The annoying side-effect: a busy web server making lots of outbound connections (e.g. to backend services) accumulates lots of TIME_WAIT sockets on the source side, eating ephemeral ports.
$ netstat -an | grep TIME_WAIT | wc -l
12384
Mitigations:
- HTTP keep-alive / connection pooling — reuse connections instead of opening new ones.
SO_REUSEADDR/SO_REUSEPORT— let new sockets bind even when there’s TIME_WAIT for the same 4-tuple.- Wider ephemeral port range —
sysctl net.ipv4.ip_local_port_rangeon Linux.
Almost never the right fix: reducing TIME_WAIT duration. It’s there for a reason.
Looking at states in practice
Linux / macOS
$ ss -tan # modern (faster than netstat)
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 128 0.0.0.0:22 0.0.0.0:*
LISTEN 0 128 0.0.0.0:443 0.0.0.0:*
ESTABLISHED 0 0 192.168.1.5:55432 8.8.8.8:443
TIME_WAIT 0 0 192.168.1.5:55400 8.8.8.8:443
$ netstat -an # older but ubiquitous
Windows
> netstat -an
Cisco IOS
R1# show tcp brief
TCB Local Address Foreign Address (state)
0xABCD123 10.0.0.1.22 10.0.0.50.55432 ESTAB
0xABCD124 10.0.0.1.22 0.0.0.0.0 LISTEN
Common state-related symptoms
| Symptom | State to investigate |
|---|---|
| Server keeps crashing under load | Lots of TIME_WAIT → port exhaustion on client side |
| Client hangs forever | Likely SYN_SENT (server unreachable) or ESTABLISHED with nothing flowing |
| Server “not listening” on the expected port | No LISTEN entry — service not actually started |
| Connection refused (RST quickly) | LISTEN absent, kernel rejects with RST |
| ”Half-open” connections after firewall changes | One side is FIN_WAIT_*, other is CLOSE_WAIT — firewall dropped teardown packets |
CLOSE_WAIT — the “your app forgot to close” smell
CLOSE_WAIT means: the remote side closed the connection (sent FIN), but your application hasn’t called close() yet. The socket sits there indefinitely.
$ ss -tan | grep CLOSE_WAIT
CLOSE_WAIT 1 0 192.168.1.5:80 10.0.0.42:55432
CLOSE_WAIT 1 0 192.168.1.5:80 10.0.0.42:55433
CLOSE_WAIT 1 0 192.168.1.5:80 10.0.0.42:55434
100 CLOSE_WAITs → likely an application bug. The app accepted connections but never properly closed them. Fix: trace the application’s connection lifecycle.
Common mistakes
-
Lowering TIME_WAIT to “free ports.” Defeats the purpose. The right fix is connection reuse, not shortened TIME_WAIT.
-
Confusing TIME_WAIT count with active connections. TIME_WAIT sockets aren’t doing anything — they’re memory-only. They look alarming but aren’t a problem unless you’re hitting port exhaustion.
-
Restarting an app and finding the port “in use.” Likely TIME_WAIT for that port’s old connections. Wait 60-120s or use
SO_REUSEADDR. -
Mistaking CLOSE_WAIT for normal. CLOSE_WAIT accumulation means application bug. Not network’s fault.
-
Thinking SYN_SENT for a long time means slow server. It usually means the server is unreachable or filtered — packets aren’t getting through. Check connectivity and firewall.
-
Using
netstat -anon a busy server. Slow. Modernssis much faster.ss -tanfor TCP,ss -uanfor UDP.
Lab to try tonight
- Open two terminals. In one:
nc -l 8080(Linux/macOS netcat listener). - In the other:
ss -tan | grep 8080. See the LISTEN state. - From the second terminal:
nc localhost 8080. Watch ss: now ESTABLISHED on both sides. - In the first terminal (nc listener): press Ctrl+C to close. Watch ss again: state transitions to TIME_WAIT briefly, then disappears.
- Open Wireshark, filter
tcp.port == 8080. Repeat the above. Match the packet exchange to state transitions. - Bonus: start a Python
http.serveron port 80, runwrkorabbenchmark against it, watch your TIME_WAIT count climb on the client.
Cheat strip
| State | Meaning |
|---|---|
| CLOSED | Default. No connection. |
| LISTEN | Server waiting for clients |
| SYN_SENT | Client has sent SYN, waiting for SYN-ACK |
| SYN_RECEIVED | Server got SYN, sent SYN-ACK, waiting for ACK |
| ESTABLISHED | Open. Data flows. |
| FIN_WAIT_1 | We sent FIN, waiting for ACK |
| FIN_WAIT_2 | We got ACK, waiting for their FIN |
| TIME_WAIT | We sent the final ACK, waiting 2 × MSL |
| CLOSE_WAIT | They sent FIN — our app hasn’t called close() yet (bug!) |
| LAST_ACK | We sent FIN after CLOSE_WAIT, waiting for final ACK |
| CLOSING | Both sides sent FIN simultaneously (rare) |
ss -tan | The daily-driver command |